mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
Stop conflict checks and continuations after shutdown
This commit is contained in:
1 parent
6d811f80e1
commit
d923510698
4 files changed
+389
-8
No files matched your search
@@ -25,6 +25,8 @@ export interface ConflictCheckingDependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConflictCheckingHandlers {
|
export interface ConflictCheckingHandlers {
|
||||||
|
/** Stop new checks and logging from pending checks after shutdown. */
|
||||||
|
readonly dispose: () => void;
|
||||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||||
@@ -32,11 +34,14 @@ export interface ConflictCheckingHandlers {
|
|||||||
|
|
||||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||||
|
let disposed = false;
|
||||||
const conflictQueue = createConflictCheckQueue({
|
const conflictQueue = createConflictCheckQueue({
|
||||||
async process(filename) {
|
async process(filename) {
|
||||||
|
if (disposed) return;
|
||||||
try {
|
try {
|
||||||
await dependencies.conflict.resolve(filename);
|
await dependencies.conflict.resolve(filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (disposed) return;
|
||||||
dependencies.log(`Conflict check failed: ${filename}`);
|
dependencies.log(`Conflict check failed: ${filename}`);
|
||||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||||
}
|
}
|
||||||
@@ -45,11 +50,13 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
|||||||
// and automatic merges can use the remaining execution slots.
|
// and automatic merges can use the remaining execution slots.
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
onProgress(remaining) {
|
onProgress(remaining) {
|
||||||
|
// Accepted checks still update their accounting as they settle.
|
||||||
dependencies.conflictProcessQueueCount.value = remaining;
|
dependencies.conflictProcessQueueCount.value = remaining;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
const path = file;
|
const path = file;
|
||||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||||
@@ -62,7 +69,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
|||||||
};
|
};
|
||||||
|
|
||||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||||
|
if (disposed) return;
|
||||||
if (optionalConflictResult == true) {
|
if (optionalConflictResult == true) {
|
||||||
// The conflict has been resolved by another process.
|
// The conflict has been resolved by another process.
|
||||||
return;
|
return;
|
||||||
@@ -79,8 +88,12 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
||||||
|
const dispose = (): void => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
dispose,
|
||||||
queueCheckForIfOpen,
|
queueCheckForIfOpen,
|
||||||
queueCheckFor,
|
queueCheckFor,
|
||||||
ensureAllProcessed,
|
ensureAllProcessed,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type MetaEntry,
|
type MetaEntry,
|
||||||
type ObsidianLiveSyncSettings,
|
type ObsidianLiveSyncSettings,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} 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 { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +44,7 @@ type HarnessOptions = {
|
|||||||
function createHarness(options: HarnessOptions = {}) {
|
function createHarness(options: HarnessOptions = {}) {
|
||||||
const context = createServiceContext();
|
const context = createServiceContext();
|
||||||
const conflict = new InjectableConflictService(context);
|
const conflict = new InjectableConflictService(context);
|
||||||
|
const unloadHandlers: Array<() => Promise<boolean>> = [];
|
||||||
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
||||||
const tryAutoMerge = vi.fn();
|
const tryAutoMerge = vi.fn();
|
||||||
const databaseFileAccess = {
|
const databaseFileAccess = {
|
||||||
@@ -62,7 +63,14 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
const activeFile = vi.fn(() => options.activeFile);
|
const activeFile = vi.fn(() => options.activeFile);
|
||||||
const services = {
|
const services = {
|
||||||
API: { addLog },
|
API: { addLog },
|
||||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
appLifecycle: {
|
||||||
|
isSuspended: vi.fn(() => false),
|
||||||
|
onUnload: {
|
||||||
|
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||||
|
unloadHandlers.push(handler);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
conflict,
|
conflict,
|
||||||
context,
|
context,
|
||||||
database: { localDatabase: { tryAutoMerge } },
|
database: { localDatabase: { tryAutoMerge } },
|
||||||
@@ -88,9 +96,22 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
serviceModules,
|
serviceModules,
|
||||||
storageAccess,
|
storageAccess,
|
||||||
tryAutoMerge,
|
tryAutoMerge,
|
||||||
|
unload: async () => {
|
||||||
|
for (const handler of unloadHandlers) await handler();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
||||||
return { rev, data, mtime, ctime: mtime, deleted: false };
|
return { rev, data, mtime, ctime: mtime, deleted: false };
|
||||||
}
|
}
|
||||||
@@ -919,6 +940,306 @@ describe("conflict resolution serviceFeature", () => {
|
|||||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
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<void>();
|
||||||
|
const pendingResolve = deferred<void>();
|
||||||
|
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<void>();
|
||||||
|
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<void>();
|
||||||
|
const pendingStore = deferred<boolean>();
|
||||||
|
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<void>();
|
||||||
|
const pendingDelete = deferred<boolean>();
|
||||||
|
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<void>();
|
||||||
|
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<void>();
|
||||||
|
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<void>();
|
||||||
|
const releaseResolvers = deferred<void>();
|
||||||
|
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<void>();
|
||||||
|
const pendingNames = deferred<FilePathWithPrefix[]>();
|
||||||
|
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 () => {
|
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
|
||||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||||
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
|
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
|
||||||
|
|||||||
@@ -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 type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
import { createConflictCheckingHandlers } from "./checker";
|
import { createConflictCheckingHandlers } from "./checker";
|
||||||
import { createConflictResolutionOperations } from "./operations";
|
import { createConflictResolutionOperations } from "./operations";
|
||||||
|
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||||
|
|
||||||
export type ConflictResolutionHost = NecessaryServices<
|
export type ConflictResolutionHost = NecessaryServices<
|
||||||
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||||
@@ -47,6 +48,18 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
|
|||||||
services.conflict.resolve.setHandler(operations.resolve);
|
services.conflict.resolve.setHandler(operations.resolve);
|
||||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
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";
|
export { createConflictCheckingHandlers } from "./checker";
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface ConflictResolutionOperationsDependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConflictResolutionOperations {
|
export interface ConflictResolutionOperations {
|
||||||
|
/** Stop continuations without cancelling file or database operations already started. */
|
||||||
|
readonly dispose: () => void;
|
||||||
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
|
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
|
||||||
readonly resolveByDeletingRevision: (
|
readonly resolveByDeletingRevision: (
|
||||||
path: FilePathWithPrefix,
|
path: FilePathWithPrefix,
|
||||||
@@ -68,13 +70,21 @@ export interface ConflictResolutionOperations {
|
|||||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create conflict-resolution operations which stop their continuations when disposed. */
|
||||||
export function createConflictResolutionOperations(
|
export function createConflictResolutionOperations(
|
||||||
dependencies: ConflictResolutionOperationsDependencies
|
dependencies: ConflictResolutionOperationsDependencies
|
||||||
): ConflictResolutionOperations {
|
): ConflictResolutionOperations {
|
||||||
|
let disposed = false;
|
||||||
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||||
let nextResolveRequestId = 0;
|
let nextResolveRequestId = 0;
|
||||||
|
|
||||||
|
const dispose = (): void => {
|
||||||
|
disposed = true;
|
||||||
|
latestResolveRequestByFilename.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const cancelActiveResolution = (filename: FilePathWithPrefix): void => {
|
const cancelActiveResolution = (filename: FilePathWithPrefix): void => {
|
||||||
|
if (disposed) return;
|
||||||
latestResolveRequestByFilename.delete(filename);
|
latestResolveRequestByFilename.delete(filename);
|
||||||
if (isLockAcquired(`conflict-resolve:${filename}`)) {
|
if (isLockAcquired(`conflict-resolve:${filename}`)) {
|
||||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||||
@@ -87,8 +97,11 @@ export function createConflictResolutionOperations(
|
|||||||
subTitle = "",
|
subTitle = "",
|
||||||
showNotice = true
|
showNotice = true
|
||||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
||||||
|
if (disposed) return MISSING_OR_ERROR;
|
||||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
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(
|
dependencies.log(
|
||||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||||
LOG_LEVEL_NOTICE
|
LOG_LEVEL_NOTICE
|
||||||
@@ -100,7 +113,9 @@ export function createConflictResolutionOperations(
|
|||||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||||
LOG_LEVEL_INFO
|
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);
|
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||||
return AUTO_MERGED;
|
return AUTO_MERGED;
|
||||||
}
|
}
|
||||||
@@ -111,7 +126,9 @@ export function createConflictResolutionOperations(
|
|||||||
// If no conflicts were found, write the resolved content to the storage.
|
// If no conflicts were found, write the resolved content to the storage.
|
||||||
const documentPath = parseDocumentPath(path);
|
const documentPath = parseDocumentPath(path);
|
||||||
const { path: filePath } = splitDocumentPath(documentPath);
|
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);
|
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||||
return MISSING_OR_ERROR;
|
return MISSING_OR_ERROR;
|
||||||
}
|
}
|
||||||
@@ -121,9 +138,11 @@ export function createConflictResolutionOperations(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
||||||
|
if (disposed) return CANCELLED;
|
||||||
const ret = await dependencies
|
const ret = await dependencies
|
||||||
.localDatabase()
|
.localDatabase()
|
||||||
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
||||||
|
if (disposed) return CANCELLED;
|
||||||
if ("ok" in ret) {
|
if ("ok" in ret) {
|
||||||
return ret.ok;
|
return ret.ok;
|
||||||
}
|
}
|
||||||
@@ -131,7 +150,9 @@ export function createConflictResolutionOperations(
|
|||||||
if ("result" in ret) {
|
if ("result" in ret) {
|
||||||
const p = ret.result;
|
const p = ret.result;
|
||||||
// 1. Store the merged content to the storage.
|
// 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);
|
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||||
return MISSING_OR_ERROR;
|
return MISSING_OR_ERROR;
|
||||||
}
|
}
|
||||||
@@ -186,6 +207,7 @@ export function createConflictResolutionOperations(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
const requestId = ++nextResolveRequestId;
|
const requestId = ++nextResolveRequestId;
|
||||||
latestResolveRequestByFilename.set(filename, requestId);
|
latestResolveRequestByFilename.set(filename, requestId);
|
||||||
const serialisationKey = `conflict-resolve:${filename}`;
|
const serialisationKey = `conflict-resolve:${filename}`;
|
||||||
@@ -196,12 +218,12 @@ export function createConflictResolutionOperations(
|
|||||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||||
}
|
}
|
||||||
return await withLock(serialisationKey, async () => {
|
return await withLock(serialisationKey, async () => {
|
||||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
||||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||||
@@ -223,6 +245,7 @@ export function createConflictResolutionOperations(
|
|||||||
interaction: NO_INTERACTION,
|
interaction: NO_INTERACTION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (disposed) return;
|
||||||
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
||||||
await dependencies.conflict.queueCheckFor(filename);
|
await dependencies.conflict.queueCheckFor(filename);
|
||||||
return;
|
return;
|
||||||
@@ -249,12 +272,15 @@ export function createConflictResolutionOperations(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
||||||
|
if (disposed) return false;
|
||||||
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||||
|
if (disposed) return false;
|
||||||
if (currentRev == false) {
|
if (currentRev == false) {
|
||||||
dependencies.log(`Could not get current revision of ${filename}`);
|
dependencies.log(`Could not get current revision of ${filename}`);
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
||||||
|
if (disposed) return false;
|
||||||
if (revs.length == 0) {
|
if (revs.length == 0) {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
@@ -278,25 +304,31 @@ export function createConflictResolutionOperations(
|
|||||||
}
|
}
|
||||||
return diff;
|
return diff;
|
||||||
});
|
});
|
||||||
|
if (disposed) return false;
|
||||||
dependencies.log(
|
dependencies.log(
|
||||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||||
);
|
);
|
||||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||||
|
if (disposed) return false;
|
||||||
dependencies.log(
|
dependencies.log(
|
||||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||||
);
|
);
|
||||||
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||||
}
|
}
|
||||||
|
if (disposed) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||||
|
|
||||||
const files = await dependencies.storageAccess.getFileNames();
|
const files = await dependencies.storageAccess.getFileNames();
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
if (disposed) return;
|
||||||
i++;
|
i++;
|
||||||
if (i % 10 === 0)
|
if (i % 10 === 0)
|
||||||
dependencies.log(
|
dependencies.log(
|
||||||
@@ -306,10 +338,12 @@ export function createConflictResolutionOperations(
|
|||||||
);
|
);
|
||||||
await resolveByNewest(file, false);
|
await resolveByNewest(file, false);
|
||||||
}
|
}
|
||||||
|
if (disposed) return;
|
||||||
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
dispose,
|
||||||
cancelActiveResolution,
|
cancelActiveResolution,
|
||||||
resolveByDeletingRevision,
|
resolveByDeletingRevision,
|
||||||
checkConflictAndPerformAutoMerge,
|
checkConflictAndPerformAutoMerge,
|
||||||
|
|||||||
Reference in new issue
Block a user