From 99d9f4c3806e0f9ada94cd171f82d68feafe92a1 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 5 Nov 2026 08:36:39 +0000 Subject: [PATCH] Own conflict cancellation at the feature boundary --- .../conflictResolution/checker.ts | 20 ++-- .../conflictShutdown.integration.spec.ts | 9 +- .../conflictResolution/index.ts | 12 +- .../interactiveConflictResolution/index.ts | 16 +-- ...interactiveConflictResolution.unit.spec.ts | 40 ++++--- .../operations.ts | 113 ++++++++++-------- 6 files changed, 115 insertions(+), 95 deletions(-) diff --git a/src/serviceFeatures/conflictResolution/checker.ts b/src/serviceFeatures/conflictResolution/checker.ts index 8bd145d9..6798fadd 100644 --- a/src/serviceFeatures/conflictResolution/checker.ts +++ b/src/serviceFeatures/conflictResolution/checker.ts @@ -17,6 +17,7 @@ export interface ConflictCheckingDependencies { IConflictService, "getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest" >; + readonly signal: AbortSignal; readonly conflictProcessQueueCount: ReactiveSource; readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void; readonly currentSettings: () => ConflictCheckingSettings; @@ -25,8 +26,6 @@ export interface ConflictCheckingDependencies { } export interface ConflictCheckingHandlers { - /** Stop admission and dispatch while allowing started checks to settle. */ - readonly dispose: () => void; readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise; readonly queueCheckFor: (file: FilePathWithPrefix) => Promise; /** Wait for active checks; false means the queue was cancelled during shutdown. */ @@ -35,7 +34,6 @@ 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) { try { @@ -55,7 +53,7 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep }); const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise => { - if (disposed) return; + if (dependencies.signal.aborted) return; const path = file; if (dependencies.currentSettings().checkConflictOnlyOnOpen) { const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath(); @@ -68,9 +66,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep }; const queueCheckFor = async (file: FilePathWithPrefix): Promise => { - if (disposed) return; + if (dependencies.signal.aborted) return; const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file); - if (disposed) return; + if (dependencies.signal.aborted) return; if (optionalConflictResult == true) { // The conflict has been resolved by another process. return; @@ -87,16 +85,18 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep }; const ensureAllProcessed = (): Promise => conflictQueue.waitForIdle(); - const dispose = (): void => { - if (disposed) return; - disposed = true; + const abortQueue = (): void => { // Shutdown keeps its existing database-close order. Observe cleanup // without making the lifecycle wait for an active resolver. void conflictQueue.abort().catch((error) => dependencies.log(error, LOG_LEVEL_VERBOSE)); }; + if (dependencies.signal.aborted) { + abortQueue(); + } else { + dependencies.signal.addEventListener("abort", abortQueue, { once: true }); + } return { - dispose, queueCheckForIfOpen, queueCheckFor, ensureAllProcessed, diff --git a/src/serviceFeatures/conflictResolution/conflictShutdown.integration.spec.ts b/src/serviceFeatures/conflictResolution/conflictShutdown.integration.spec.ts index e6f0e447..501f5cbc 100644 --- a/src/serviceFeatures/conflictResolution/conflictShutdown.integration.spec.ts +++ b/src/serviceFeatures/conflictResolution/conflictShutdown.integration.spec.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import PouchDB from "pouchdb-core"; import MemoryAdapter from "pouchdb-adapter-memory"; @@ -23,11 +22,12 @@ describe("conflict-check shutdown with a real PouchDB handle", () => { it.each([false, true])( "reports cancellation and preserves the database outcome when closed before completion: %s", async (closeDuringShutdown) => { - const name = `conflict-shutdown-${randomUUID()}`; + const name = `conflict-shutdown-${crypto.randomUUID()}`; const db = new PouchDB(name, { adapter: "memory" }); const started = deferred(); const release = deferred(); const conflict = new InjectableConflictService(createServiceContext()); + const lifetime = new AbortController(); const log = vi.fn(); const resolver = vi.fn(async () => { started.resolve(); @@ -38,6 +38,7 @@ describe("conflict-check shutdown with a real PouchDB handle", () => { conflict.resolve.setHandler(resolver); const checking = createConflictCheckingHandlers({ conflict, + signal: lifetime.signal, conflictProcessQueueCount: conflict.conflictProcessQueueCount, cancelActiveResolution: vi.fn(), currentSettings: () => ({ checkConflictOnlyOnOpen: false }), @@ -53,8 +54,8 @@ describe("conflict-check shutdown with a real PouchDB handle", () => { await checking.queueCheckFor("active.md" as FilePathWithPrefix); await started.promise; completion = checking.ensureAllProcessed(); - checking.dispose(); - checking.dispose(); + lifetime.abort(); + lifetime.abort(); await checking.queueCheckFor("late.md" as FilePathWithPrefix); if (closeDuringShutdown) { await db.close(); diff --git a/src/serviceFeatures/conflictResolution/index.ts b/src/serviceFeatures/conflictResolution/index.ts index 3026bf2d..ab8aae35 100644 --- a/src/serviceFeatures/conflictResolution/index.ts +++ b/src/serviceFeatures/conflictResolution/index.ts @@ -19,6 +19,7 @@ export type ConflictResolutionHost = NecessaryServices< /** Compose the host-neutral conflict handlers with the host's borrowed logger. */ export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => { const { services, serviceModules, log } = host; + const lifetime = new AbortController(); const operations = createConflictResolutionOperations({ events: services.context.events, databaseFileAccess: serviceModules.databaseFileAccess, @@ -39,6 +40,7 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict currentSettings: () => services.setting.currentSettings(), vault: services.vault, log, + signal: lifetime.signal, }); services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen); @@ -49,14 +51,10 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict services.conflict.resolveByNewest.setHandler(operations.resolveByNewest); services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes); - const eventSubscriptions = new AbortController(); - const dispose = (): void => { - checking.dispose(); - eventSubscriptions.abort(); - }; - services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal }); + const stop = (): void => lifetime.abort(); + services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal }); services.appLifecycle.onUnload.addHandler(() => { - dispose(); + stop(); return Promise.resolve(true); }); }); diff --git a/src/serviceFeatures/interactiveConflictResolution/index.ts b/src/serviceFeatures/interactiveConflictResolution/index.ts index a3b75e6c..c264d3d9 100644 --- a/src/serviceFeatures/interactiveConflictResolution/index.ts +++ b/src/serviceFeatures/interactiveConflictResolution/index.ts @@ -17,8 +17,10 @@ export function useInteractiveConflictResolutionFeature( createDialogue: ConflictResolveDialogueFactory ): void { const services = host.services; + const lifetime = new AbortController(); const operations = createInteractiveConflictResolutionOperations({ events: services.context.events, + signal: lifetime.signal, databaseFileAccess: host.serviceModules.databaseFileAccess, localDatabase: () => services.database.localDatabase, confirm: services.UI.confirm, @@ -61,24 +63,18 @@ export function useInteractiveConflictResolutionFeature( }); services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages); services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction); - const eventSubscriptions = new AbortController(); - const dispose = () => { - // Stop the refresh listener before cancellation so that unloading does - // not start a database read which can race with database disposal. - eventSubscriptions.abort(); - operations.dispose(); - }; services.context.events.onEvent( EVENT_CONFLICT_CANCELLED, (filename) => { operations.invalidateWaitingResolution(filename); fireAndForget(() => operations.refreshConflictState(filename)); }, - { signal: eventSubscriptions.signal } + { signal: lifetime.signal } ); - services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal }); + const stop = (): void => lifetime.abort(); + services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal }); services.appLifecycle.onUnload.addHandler(() => { - dispose(); + stop(); return Promise.resolve(true); }); } diff --git a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts index 90c70068..69f52cbf 100644 --- a/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts +++ b/src/serviceFeatures/interactiveConflictResolution/interactiveConflictResolution.unit.spec.ts @@ -37,6 +37,7 @@ function createOperations( overrides: Partial = {} ) { const context = createServiceContext(); + const lifetime = new AbortController(); let dialogueResult: unknown = POSTPONED; const constructed = { value: 0 }; const getDBEntry = vi.fn(async (): Promise => false); @@ -55,6 +56,7 @@ function createOperations( const log = vi.fn(); const operations = createInteractiveConflictResolutionOperations({ events: context.events, + signal: overrides.signal ?? lifetime.signal, databaseFileAccess: { getConflictedRevs, storeContent, @@ -83,6 +85,7 @@ function createOperations( ...overrides, } as unknown as InteractiveConflictResolutionOperationsDependencies); return { + abort: () => lifetime.abort(), askSelectString, askInPopup, constructed, @@ -154,11 +157,13 @@ function createControlledDialogueFactory( function createDialogueConcurrencyHarness() { const context = createServiceContext(); + const lifetime = new AbortController(); const emitEvent = vi.spyOn(context.events, "emitEvent"); const dialogues: ControlledDialogue[] = []; const createDialogue = createControlledDialogueFactory(context, dialogues); const operations = createInteractiveConflictResolutionOperations({ events: context.events, + signal: lifetime.signal, databaseFileAccess: { getConflictedRevs: vi.fn(async () => ["2-right"]), storeContent: vi.fn(async () => true), @@ -183,7 +188,7 @@ function createDialogueConcurrencyHarness() { createDialogue: createDialogue as ConflictResolveDialogueFactory, log: vi.fn(), } as unknown as InteractiveConflictResolutionOperationsDependencies); - return { context, createDialogue, dialogues, emitEvent, operations }; + return { abort: () => lifetime.abort(), context, createDialogue, dialogues, emitEvent, operations }; } describe("interactive conflict resolution operations", () => { @@ -218,7 +223,7 @@ describe("interactive conflict resolution operations", () => { const settled = Promise.allSettled([active, waiting]); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); unregisterLog(); finish(result); @@ -256,7 +261,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishRead(false); await expect(resolving).resolves.toBe(false); @@ -288,7 +293,7 @@ describe("interactive conflict resolution operations", () => { const resolving = fixture.operations.resolveByUserInteraction(path, conflict); await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce()); - fixture.operations.dispose(); + fixture.abort(); finishRead({ _rev: "2-left", _conflicts: ["2-right"] }); await expect(resolving).resolves.toBe(false); @@ -316,7 +321,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishStore(false); await expect(resolving).resolves.toBe(false); @@ -351,7 +356,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishStore(true); await expect(resolving).resolves.toBe(false); @@ -383,7 +388,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishDeletion(MISSING_OR_ERROR); await expect(resolving).resolves.toBe(false); @@ -417,7 +422,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishDeletion(AUTO_MERGED); await expect(resolving).resolves.toBe(false); @@ -447,7 +452,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); rejectInspection(new Error("database unavailable")); await expect(refreshing).resolves.toBeUndefined(); @@ -483,7 +488,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishInspection(["2-right"]); await expect(refreshing).resolves.toBeUndefined(); @@ -503,7 +508,7 @@ describe("interactive conflict resolution operations", () => { const messages = fixture.operations.getActiveConflictMessages(); await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); - fixture.operations.dispose(); + fixture.abort(); finishInspection(["2-right"]); await expect(messages).resolves.toEqual([]); @@ -512,7 +517,7 @@ describe("interactive conflict resolution operations", () => { it("does not inspect or publish conflict state when called after disposal", async () => { const fixture = createOperations(); const emit = vi.spyOn(fixture.context.events, "emitEvent"); - fixture.operations.dispose(); + fixture.abort(); emit.mockClear(); await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]); @@ -543,7 +548,7 @@ describe("interactive conflict resolution operations", () => { const resolving = fixture.operations.resolveByUserInteraction(path, conflict); await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); emit.mockClear(); finishInspection(["2-right"]); @@ -577,7 +582,7 @@ describe("interactive conflict resolution operations", () => { await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce()); expect(fixture.storeContent).toHaveBeenCalledOnce(); const logsBeforeDisposal = sink.mock.calls.length; - fixture.operations.dispose(); + fixture.abort(); finishDeletion(result); await expect(resolving).resolves.toBe(false); @@ -615,7 +620,7 @@ describe("interactive conflict resolution operations", () => { const resolving = fixture.operations.resolveByUserInteraction(path, conflict); await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce()); - fixture.operations.dispose(); + fixture.abort(); finishReplication({ status: "completed" }); await expect(resolving).resolves.toBe(false); @@ -695,8 +700,8 @@ describe("interactive conflict resolution operations", () => { ); await Promise.resolve(); - oldInstance.operations.dispose(); - oldInstance.operations.dispose(); + oldInstance.abort(); + oldInstance.abort(); await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]); expect(oldInstance.dialogues).toHaveLength(1); @@ -1247,6 +1252,7 @@ describe("interactive conflict resolution feature composition", () => { expect(completedOnUnload).toBe(true); expect(dialogues).toHaveLength(1); + expect(fixture.getConflictedRevs).not.toHaveBeenCalled(); await fixture.handlers.onUnload?.(); }); diff --git a/src/serviceFeatures/interactiveConflictResolution/operations.ts b/src/serviceFeatures/interactiveConflictResolution/operations.ts index 856dacfc..c140aa3c 100644 --- a/src/serviceFeatures/interactiveConflictResolution/operations.ts +++ b/src/serviceFeatures/interactiveConflictResolution/operations.ts @@ -23,6 +23,8 @@ import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/ import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import { fireAndForget } from "octagonal-wheels/promises"; import { withLock } from "octagonal-wheels/concurrency/lock"; +import { withCancellation } from "octagonal-wheels/concurrency/cancellation"; +import { withScope } from "octagonal-wheels/resource/scope"; import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication"; import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts"; @@ -38,6 +40,7 @@ import { export interface InteractiveConflictResolutionOperationsDependencies { readonly events: Pick; + readonly signal: AbortSignal; readonly databaseFileAccess: Pick; readonly localDatabase: () => Pick; readonly confirm: Pick; @@ -52,7 +55,6 @@ export interface InteractiveConflictResolutionOperationsDependencies { } export interface InteractiveConflictResolutionOperations { - readonly dispose: () => void; readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void; readonly getActiveConflictMessages: () => Promise; readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise; @@ -77,9 +79,18 @@ export function createInteractiveConflictResolutionOperations( const latestRequestByFilename = new Map(); let nextRequestId = 0; let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined; - let disposed = false; + const clearPendingState = (): void => { + latestRequestByFilename.clear(); + postponedConflictEpisodes.clear(); + }; + if (dependencies.signal.aborted) { + clearPendingState(); + } else { + dependencies.signal.addEventListener("abort", clearPendingState, { once: true }); + } const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => { + if (dependencies.signal.aborted) return; // An active dialogue consumes this event itself. Removing its request // here would also remove a same-path replacement which emitted the // event to close that active dialogue. A non-active request is stale @@ -88,23 +99,11 @@ export function createInteractiveConflictResolutionOperations( latestRequestByFilename.delete(filename); }; - const dispose = (): void => { - if (disposed) return; - disposed = true; - latestRequestByFilename.clear(); - postponedConflictEpisodes.clear(); - const activeFilename = activeDialogue?.filename; - if (activeFilename !== undefined) { - dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename); - activeDialogue = undefined; - } - }; - const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise => { - if (disposed) return undefined; + if (dependencies.signal.aborted) return undefined; try { const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length; - if (disposed) return undefined; + if (dependencies.signal.aborted) return undefined; return conflictCount === 0 ? 0 : conflictCount + 1; } catch (error) { dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE); @@ -114,11 +113,11 @@ export function createInteractiveConflictResolutionOperations( }; const getActiveConflictMessages = async (): Promise => { - if (disposed) return []; + if (dependencies.signal.aborted) return []; const filename = dependencies.vault.getActiveFilePath(); if (!filename) return []; const versionCount = await getConflictVersionCount(filename); - if (disposed) return []; + if (dependencies.signal.aborted) return []; if (versionCount === 0) { postponedConflictEpisodes.delete(filename); return []; @@ -137,9 +136,9 @@ export function createInteractiveConflictResolutionOperations( }; const refreshConflictState = async (filename: FilePathWithPrefix): Promise => { - if (disposed) return; + if (dependencies.signal.aborted) return; const versionCount = await getConflictVersionCount(filename); - if (disposed) return; + if (dependencies.signal.aborted) return; if (versionCount === 0) { postponedConflictEpisodes.delete(filename); } @@ -147,9 +146,11 @@ export function createInteractiveConflictResolutionOperations( }; const requestConflictResolution = async (filename: FilePathWithPrefix): Promise => { + if (dependencies.signal.aborted) return false; postponedConflictEpisodes.delete(filename); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); await dependencies.conflict.queueCheckFor(filename); + if (dependencies.signal.aborted) return false; return await dependencies.conflict.ensureAllProcessed(); }; @@ -157,22 +158,28 @@ export function createInteractiveConflictResolutionOperations( filename: FilePathWithPrefix, conflictCheckResult: diff_result ): Promise => { - if (disposed) return false; - const requestId = ++nextRequestId; - if (activeDialogue?.filename === filename) { - dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename); - activeDialogue = undefined; - } - latestRequestByFilename.set(filename, requestId); - - // UI for resolving different files should proceed one-by-one. A newer - // request for the active file replaces its dialogue instead of waiting - // behind a comparison which is already stale. - return await withLock(dialogueSerialisationKey, async () => { - if (disposed || latestRequestByFilename.get(filename) !== requestId) { - return false; + if (dependencies.signal.aborted) return false; + return await withScope(undefined, async (request) => { + if (dependencies.signal.aborted) return false; + const requestId = ++nextRequestId; + if (activeDialogue?.filename === filename) { + dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename); + activeDialogue = undefined; } - try { + latestRequestByFilename.set(filename, requestId); + request.defer(() => { + if (latestRequestByFilename.get(filename) === requestId) { + latestRequestByFilename.delete(filename); + } + }); + + // UI for resolving different files should proceed one-by-one. A newer + // request for the active file replaces its dialogue instead of waiting + // behind a comparison which is already stale. + return await withLock(dialogueSerialisationKey, async () => { + if (dependencies.signal.aborted || latestRequestByFilename.get(filename) !== requestId) { + return false; + } if (postponedConflictEpisodes.has(filename)) { dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); @@ -183,14 +190,24 @@ export function createInteractiveConflictResolutionOperations( activeDialogue = { filename, dialogue }; let selected: MergeDialogResult; try { - dialogue.open(); - selected = await dialogue.waitForResult(); + selected = await withCancellation(dialogue, { signal: dependencies.signal }, async (waiting) => { + const cancelDialogue = (): void => { + if (activeDialogue?.dialogue === dialogue) { + dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename); + } + }; + waiting.signal.addEventListener("abort", cancelDialogue, { once: true }); + waiting.defer(() => waiting.signal.removeEventListener("abort", cancelDialogue)); + dialogue.open(); + if (waiting.isCancelled()) cancelDialogue(); + return await dialogue.waitForResult(); + }); } finally { if (activeDialogue?.dialogue === dialogue) { activeDialogue = undefined; } } - if (disposed) return false; + if (dependencies.signal.aborted) return false; if (selected === POSTPONED) { postponedConflictEpisodes.add(filename); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); @@ -212,7 +229,7 @@ export function createInteractiveConflictResolutionOperations( if (!testDoc._conflicts || testDoc._conflicts.length === 0) { dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE); await refreshConflictState(filename); - if (disposed) return false; + if (dependencies.signal.aborted) return false; return false; } if ( @@ -224,7 +241,7 @@ export function createInteractiveConflictResolutionOperations( LOG_LEVEL_INFO ); await refreshConflictState(filename); - if (disposed) return false; + if (dependencies.signal.aborted) return false; await dependencies.conflict.queueCheckFor(filename); return false; } @@ -286,17 +303,15 @@ export function createInteractiveConflictResolutionOperations( // And, check it again. await dependencies.conflict.queueCheckFor(filename); return false; - } finally { - if (latestRequestByFilename.get(filename) === requestId) { - latestRequestByFilename.delete(filename); - } - } + }); }); }; const pickFileForResolve = async (notifyIfEmpty = true): Promise => { + if (dependencies.signal.aborted) return false; const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = []; for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) { + if (dependencies.signal.aborted) return false; if (!("_conflicts" in doc)) continue; notes.push({ id: doc._id, @@ -305,6 +320,7 @@ export function createInteractiveConflictResolutionOperations( mtime: doc.mtime, }); } + if (dependencies.signal.aborted) return false; notes.sort((a, b) => b.mtime - a.mtime); const notesList = notes.map((e) => e.dispPath); if (notesList.length == 0) { @@ -314,6 +330,7 @@ export function createInteractiveConflictResolutionOperations( return false; } const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList); + if (dependencies.signal.aborted) return false; if (target) { const targetItem = notes.find((e) => e.dispPath == target)!; await requestConflictResolution(targetItem.path); @@ -324,19 +341,22 @@ export function createInteractiveConflictResolutionOperations( const allConflictCheck = async (): Promise => { let notifyIfEmpty = true; - while (await pickFileForResolve(notifyIfEmpty)) { + while (!dependencies.signal.aborted && (await pickFileForResolve(notifyIfEmpty))) { notifyIfEmpty = false; } }; const scanStartupIssues = async (): Promise => { + if (dependencies.signal.aborted) return false; const notes: { path: string; mtime: number }[] = []; dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE); try { for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) { + if (dependencies.signal.aborted) return false; if (!("_conflicts" in doc)) continue; notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime }); } + if (dependencies.signal.aborted) return false; if (notes.length > 0) { dependencies.confirm.askInPopup( `conflicting-detected-on-safety`, @@ -367,7 +387,6 @@ export function createInteractiveConflictResolutionOperations( }; return { - dispose, invalidateWaitingResolution, getActiveConflictMessages, refreshConflictState,