Stop conflict work after disposal and verify reload preservation

Stop pending dialogue and selected-resolution continuations after disposal,
before they log or start further resolution work. Verify released log binders
and retain real-Obsidian checks for content and revision history across reload.
This commit is contained in:
vorotamoroz committed 2026-11-03 08:44:31 +00:00
1 parent d557298ceb
commit 6f8df8f731
4 files changed
+511 -50

No files matched your search

@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
AUTO_MERGED,
CANCELLED,
@@ -30,7 +32,10 @@ async function* documents(items: unknown[]) {
}
}
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
function createOperations(
conflictedRevisions: string[] = ["2-right"],
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
) {
const context = createServiceContext();
let dialogueResult: unknown = POSTPONED;
const constructed = { value: 0 };
@@ -75,6 +80,7 @@ function createOperations(conflictedRevisions: string[] = ["2-right"]) {
};
}),
log,
...overrides,
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return {
askSelectString,
@@ -181,6 +187,271 @@ function createDialogueConcurrencyHarness() {
}
describe("interactive conflict resolution operations", () => {
it.each([
{ label: "cancelled", result: CANCELLED },
{ label: "postponed", result: POSTPONED },
{ label: "selected", result: "2-right" },
] as const)("ignores a $label dialogue result which settles after disposal", async ({ result }) => {
let finish!: (result: MergeDialogResult) => void;
const response = new Promise<MergeDialogResult>((resolve) => {
finish = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const createDialogue = vi.fn(() => ({ open: vi.fn(), waitForResult: () => response }));
const fixture = createOperations(undefined, {
createDialogue,
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
const active = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(createDialogue).toHaveBeenCalledOnce());
const waiting = fixture.operations.resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
const settled = Promise.allSettled([active, waiting]);
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finish(result);
expect(await settled).toEqual([
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: false },
]);
expect(createDialogue).toHaveBeenCalledOnce();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.getDBEntry).not.toHaveBeenCalled();
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not continue a selected conflict after the database read settles following disposal", async () => {
let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishRead(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not log a failed content store after disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishStore(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not delete or requeue after content storage succeeds following disposal", async () => {
let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockReturnValue(pendingStore);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishStore(true);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not log a failed revision deletion after disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishDeletion(MISSING_OR_ERROR);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not replicate or requeue after revision deletion succeeds following disposal", async () => {
let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockReturnValue(pendingDeletion);
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishDeletion(AUTO_MERGED);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not log or emit a conflict refresh after a pending inspection rejects following disposal", async () => {
let rejectInspection!: (reason: unknown) => void;
const pendingInspection = new Promise<string[]>((_resolve, reject) => {
rejectInspection = reject;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
rejectInspection(new Error("database unavailable"));
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("does not emit a successful conflict refresh after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
const refreshing = fixture.operations.refreshConflictState(path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishInspection(["2-right"]);
await expect(refreshing).resolves.toBeUndefined();
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
it("returns no active conflict messages when inspection settles after disposal", async () => {
let finishInspection!: (revisions: string[]) => void;
const pendingInspection = new Promise<string[]>((resolve) => {
finishInspection = resolve;
});
const fixture = createOperations(undefined, {
log: vi.fn(),
});
fixture.getConflictedRevs.mockReturnValue(pendingInspection);
const messages = fixture.operations.getActiveConflictMessages();
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
fixture.operations.dispose();
finishInspection(["2-right"]);
await expect(messages).resolves.toEqual([]);
});
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness();
@@ -100,10 +100,13 @@ export function createInteractiveConflictResolutionOperations(
};
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
if (disposed) return undefined;
try {
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
if (disposed) return undefined;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
if (disposed) return undefined;
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.log(error, LOG_LEVEL_VERBOSE);
return undefined;
@@ -111,9 +114,11 @@ export function createInteractiveConflictResolutionOperations(
};
const getActiveConflictMessages = async (): Promise<string[]> => {
if (disposed) return [];
const filename = dependencies.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await getConflictVersionCount(filename);
if (disposed) return [];
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
return [];
@@ -132,7 +137,10 @@ export function createInteractiveConflictResolutionOperations(
};
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
if ((await getConflictVersionCount(filename)) === 0) {
if (disposed) return;
const versionCount = await getConflictVersionCount(filename);
if (disposed) return;
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
}
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -182,6 +190,7 @@ export function createInteractiveConflictResolutionOperations(
activeDialogue = undefined;
}
}
if (disposed) return false;
if (selected === POSTPONED) {
postponedConflictEpisodes.add(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -196,6 +205,7 @@ export function createInteractiveConflictResolutionOperations(
const testDoc = await dependencies
.localDatabase()
.getDBEntry(filename, { conflicts: true }, false, true, true);
if (disposed) return false;
if (testDoc === false) {
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
@@ -203,6 +213,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;
return false;
}
if (
@@ -214,6 +225,7 @@ export function createInteractiveConflictResolutionOperations(
LOG_LEVEL_INFO
);
await refreshConflictState(filename);
if (disposed) return false;
await dependencies.conflict.queueCheckFor(filename);
return false;
}
@@ -224,15 +236,20 @@ export function createInteractiveConflictResolutionOperations(
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p);
if (disposed) return false;
if (!contentStored) {
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
MISSING_OR_ERROR
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
delRev,
"UI Concatenated"
);
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
@@ -244,10 +261,13 @@ export function createInteractiveConflictResolutionOperations(
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
MISSING_OR_ERROR
) {
const deletionResult = await dependencies.conflict.resolveByDeletingRevision(
filename,
toDelete,
"UI Selected"
);
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
@@ -266,6 +286,7 @@ export function createInteractiveConflictResolutionOperations(
trigger: "merge",
interaction: NO_INTERACTION,
});
if (disposed) return false;
}
// And, check it again.
await dependencies.conflict.queueCheckFor(filename);