fix: preserve operation outcomes during plug-in shutdown

Keep API logging callable after display cleanup by forwarding late messages to the console. Abort conflict-check admission and pending dispatch, and propagate cancellation to waiters.

Let started data operations follow their existing success and failure paths without delaying database close. Verify repeated shutdown, instance isolation, and unload/reload in unit, PouchDB, and real Obsidian tests.
This commit is contained in:
vorotamoroz committed 2026-11-04 10:29:35 +00:00
1 parent d923510698
commit 4d46afc5cd
13 files changed
+603 -184

No files matched your search

+10 -4
View File
@@ -81,6 +81,10 @@ function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
};
}
function logToConsole(message: unknown, level?: number, key?: string) {
console.log({ message, level, key });
}
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
@@ -126,7 +130,7 @@ function redactLog(log: string) {
const showDebugLog = false;
export const MARK_DONE = "\u{2009}\u{2009}";
export class ModuleLog extends AbstractObsidianModule {
/** The host releases this connection after the complete asynchronous unload. */
/** The host releases log display subscriptions after the complete asynchronous unload. */
logResource!: OwnedResource<LogFunction>;
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
private readonly displayLogEntries = () => {
@@ -615,8 +619,10 @@ ${stringifyYaml(info)}
}
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
const log = createBufferedLog(this.recentLogEntries);
const unregisterLog = services.API.addLog.setHandler(log);
const bufferedLog = createBufferedLog(this.recentLogEntries);
let logTarget: LogFunction = bufferedLog;
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
services.API.addLog.setHandler(log);
const collectGlobalLogs = () => {
const entries = recentGlobalLogEntries.value;
if (entries.length === 0) return;
@@ -624,7 +630,7 @@ ${stringifyYaml(info)}
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
};
this.logResource = createOwnedResource(log, () => {
unregisterLog();
logTarget = logToConsole;
recentGlobalLogEntries.offChanged(collectGlobalLogs);
this.recentLogEntries.offChanged(this.displayLogEntries);
this.recentLogEntries.value = [];
+93 -5
View File
@@ -107,17 +107,81 @@ describe("ModuleLog service connection", () => {
]);
});
it("detaches both service registration and legacy subscription on disposal", async () => {
it("keeps service logging callable after disposal and sends late logs to the console", async () => {
const { services, module, sink } = await createHarness();
await services.appLifecycle.onSettingLoaded();
sink.mockClear();
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
await module.logResource.dispose();
try {
expect(() => services.API.addLog("late-service", 32, "late-key")).not.toThrow();
expect(consoleLog).toHaveBeenCalledExactlyOnceWith({
message: "late-service",
level: 32,
key: "late-key",
});
const cause = new Error("root cause");
const error = Object.assign(new Error("late failure"), { cause });
services.API.addLog(error, 128, "late-error");
const errorEntry = consoleLog.mock.calls[1][0] as {
message: Error & { cause?: unknown };
level?: number;
key?: string;
};
expect(errorEntry.message).toBe(error);
expect(errorEntry.message.cause).toBe(cause);
expect(errorEntry.level).toBe(128);
expect(errorEntry.key).toBe("late-error");
expect(sink).not.toHaveBeenCalled();
} finally {
consoleLog.mockRestore();
}
});
it("sends late circular service messages directly to the console", async () => {
const { services, module, sink } = await createHarness();
await services.appLifecycle.onSettingLoaded();
sink.mockClear();
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
const circular: { self?: unknown } = {};
circular.self = circular;
try {
await module.logResource.dispose();
expect(() => services.API.addLog(circular, 32, "circular")).not.toThrow();
const consoleEntry = consoleLog.mock.calls[0][0] as {
message: unknown;
level?: number;
key?: string;
};
expect(consoleEntry.message).toBe(circular);
expect(consoleEntry.level).toBe(32);
expect(consoleEntry.key).toBe("circular");
expect(sink).not.toHaveBeenCalled();
} finally {
consoleLog.mockRestore();
}
});
it("releases legacy and UI subscriptions while retaining late service console logging", async () => {
const first = await createHarness();
const second = await createHarness();
await first.services.appLifecycle.onSettingLoaded();
await second.services.appLifecycle.onSettingLoaded();
first.sink.mockClear();
second.sink.mockClear();
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await first.module.logResource.dispose();
await first.module.logResource.dispose();
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
expect(() => first.services.API.addLog("retired", 32, "retired-key")).not.toThrow();
expect(consoleLog).toHaveBeenCalledWith({
message: "retired",
level: 32,
key: "retired-key",
});
second.services.API.addLog("active", 32, "service");
first.Logger("legacy-after-retirement", 32, "legacy");
expect(first.sink).not.toHaveBeenCalled();
@@ -126,6 +190,9 @@ describe("ModuleLog service connection", () => {
["legacy-after-retirement", 32, "legacy"],
]);
await second.module.logResource.dispose();
} finally {
consoleLog.mockRestore();
}
});
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
@@ -143,20 +210,31 @@ describe("ModuleLog service connection", () => {
expect(sink).not.toHaveBeenCalled();
});
it("releases a connection which never reached the settings-loaded phase", async () => {
it("retires a connection which never reached the settings-loaded phase", async () => {
const { services, module, sink, Logger } = await createHarness();
services.API.addLog("pending");
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await module.logResource.dispose();
Logger("after-disposal");
expect(() => services.API.addLog("retired", 32, "retired-key")).not.toThrow();
expect(consoleLog).toHaveBeenCalledWith({
message: "retired",
level: 32,
key: "retired-key",
});
expect(sink).not.toHaveBeenCalled();
expect(() => services.API.addLog("retired")).toThrow("not assigned");
const replacement = await createHarness();
await replacement.services.appLifecycle.onSettingLoaded();
const messages = replacement.sink.mock.calls.map(([message]) => message);
expect(messages).toContain("after-disposal");
expect(messages).not.toContain("pending");
expect(messages).not.toContain("retired");
await replacement.module.logResource.dispose();
} finally {
consoleLog.mockRestore();
}
});
it.each(["complete", "fail"] as const)(
@@ -219,10 +297,20 @@ describe("ModuleLog service connection", () => {
"moduleLiveSyncMain.logUnloadingPlugin"
);
}
expect(() => services.API.addLog("retired")).toThrow("not assigned");
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
expect(() => services.API.addLog("late-after-close", 32, "late-after-close")).not.toThrow();
expect(consoleLog).toHaveBeenCalledWith({
message: "late-after-close",
level: 32,
key: "late-after-close",
});
sink.mockClear();
Logger("after-unload");
expect(sink).not.toHaveBeenCalled();
} finally {
consoleLog.mockRestore();
}
}
);
});
@@ -25,10 +25,11 @@ export interface ConflictCheckingDependencies {
}
export interface ConflictCheckingHandlers {
/** Stop new checks and logging from pending checks after shutdown. */
/** Stop admission and dispatch while allowing started checks to settle. */
readonly dispose: () => void;
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
/** Wait for active checks; false means the queue was cancelled during shutdown. */
readonly ensureAllProcessed: () => Promise<boolean>;
}
@@ -37,11 +38,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
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);
}
@@ -89,7 +88,11 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
const dispose = (): void => {
if (disposed) return;
disposed = true;
// 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));
};
return {
@@ -940,7 +940,7 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("settles queued checks without logging a resolver rejection after unload", async () => {
it("reports queue cancellation and logs a resolver rejection after unload", async () => {
const path = "queued-rejection-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
@@ -959,17 +959,16 @@ describe("conflict resolution serviceFeature", () => {
);
await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingResolve.reject(new Error("Resolver rejected after unload"));
await harness.unload();
const failure = new Error("Resolver rejected after unload");
pendingResolve.reject(failure);
await expect(completion).resolves.toEqual({ status: "resolved", result: true });
await expect(completion).resolves.toEqual({ status: "resolved", result: false });
expect(resolve).toHaveBeenCalledOnce();
expect(harness.addLog).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("does not emit, log, or requeue a pending NOT_CONFLICTED result after the unload event", async () => {
it("allows an active inspection to complete normally after unload", async () => {
const path = "not-conflicted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
@@ -989,18 +988,15 @@ describe("conflict resolution serviceFeature", () => {
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(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(queueCheckFor).not.toHaveBeenCalled();
expect(harness.addLog).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalled();
});
it("does not delete a conflict or requeue when auto-merge storage completes after unload", async () => {
it("continues an accepted auto-merge when 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");
@@ -1021,19 +1017,18 @@ describe("conflict resolution serviceFeature", () => {
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();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
expect(harness.fileHandler.dbToStorage).toHaveBeenCalled();
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
});
it("does not continue revision deletion after the unload event", async () => {
it("reflects a revision deletion which succeeds after the unload event", async () => {
const path = "deleted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const started = deferred<void>();
@@ -1052,19 +1047,16 @@ describe("conflict resolution serviceFeature", () => {
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();
await expect(completion).resolves.toEqual({ status: "resolved", result: AUTO_MERGED });
expect(harness.databaseFileAccess.getConflictedRevs).toHaveBeenCalledWith(path);
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).toHaveBeenCalled();
});
it("does not requeue or log after pending replication completes during unload", async () => {
it("lets active replication finish while rejecting its next conflict check", async () => {
const path = "replication-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: true } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
@@ -1087,14 +1079,13 @@ describe("conflict resolution serviceFeature", () => {
});
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();
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
expect(harness.addLog).toHaveBeenCalled();
});
it("does not start resolveByNewest when an optional conflict check returns newer after unload", async () => {
@@ -1133,7 +1124,7 @@ describe("conflict resolution serviceFeature", () => {
});
it.each(["lifecycle", "event"] as const)(
"settles conflict operations without side effects after %s disposal",
"stops admission and preserves ordinary database failures after %s disposal",
async (trigger) => {
const path = "operation-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
@@ -1154,9 +1145,10 @@ describe("conflict resolution serviceFeature", () => {
} else {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
}
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
const failure = new Error("database closed");
harness.tryAutoMerge.mockRejectedValue(failure);
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
try {
const results = await Promise.allSettled([
@@ -1168,26 +1160,34 @@ describe("conflict resolution serviceFeature", () => {
harness.conflict.resolveAllConflictedFilesByNewerOnes(),
]);
expect(results.every(({ status }) => status === "fulfilled")).toBe(true);
expect(results).toEqual([
{ status: "fulfilled", value: undefined },
{ status: "fulfilled", value: undefined },
{ status: "rejected", reason: failure },
{ status: "fulfilled", value: MISSING_OR_ERROR },
{ status: "fulfilled", value: false },
{ status: "fulfilled", value: undefined },
]);
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(false);
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.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.databaseFileAccess.storeContent).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledOnce();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.storageAccess.getFileNames).not.toHaveBeenCalled();
expect(harness.storageAccess.getFileNames).toHaveBeenCalledOnce();
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalled();
} finally {
unregisterOptionalCheck();
}
}
);
it("does not start a queued resolver after active checks settle during unload", async () => {
it("cancels unstarted checks 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>();
@@ -1206,12 +1206,12 @@ describe("conflict resolution serviceFeature", () => {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
releaseResolvers.resolve(undefined);
await expect(completion).resolves.toBe(true);
await expect(completion).resolves.toBe(false);
expect(resolve).toHaveBeenCalledTimes(10);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(1);
});
it("stops bulk conflict resolution when its storage scan completes after unload", async () => {
it("reports an unavailable database when an active bulk scan completes after unload", async () => {
const path = "bulk-resolution-after-unload.md" as FilePathWithPrefix;
const harness = createHarness();
const namesStarted = deferred<void>();
@@ -1227,17 +1227,58 @@ describe("conflict resolution serviceFeature", () => {
harness.addLog.mockClear();
await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
pendingNames.resolve([path]);
await expect(operation).resolves.toBeUndefined();
expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled();
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).not.toHaveBeenCalled();
expect(
harness.addLog.mock.calls.some(([message]) => String(message).includes("Could not get current revision"))
).toBe(true);
});
it("keeps a replacement queue independent of repeated shutdown of the previous queue", async () => {
const first = createHarness();
const second = createHarness();
first.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
second.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
first.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await first.unload();
await first.unload();
await first.conflict.queueCheckFor("retired.md" as FilePathWithPrefix);
await second.conflict.queueCheckFor("active.md" as FilePathWithPrefix);
await expect(first.conflict.ensureAllProcessed()).resolves.toBe(false);
await expect(second.conflict.ensureAllProcessed()).resolves.toBe(true);
expect(first.tryAutoMerge).not.toHaveBeenCalled();
expect(second.tryAutoMerge).toHaveBeenCalledExactlyOnceWith("active.md", true);
});
it("reports a storage failure after shutdown without treating the queue as completed", async () => {
const harness = createHarness();
const started = deferred<void>();
const pendingStore = deferred<boolean>();
const failure = new Error("database is closed");
harness.tryAutoMerge.mockResolvedValue({ result: "Merged content", conflictedRev: "2-conflict" });
harness.databaseFileAccess.storeContent.mockImplementation(() => {
started.resolve(undefined);
return pendingStore.promise;
});
await harness.conflict.queueCheckFor("closed-database.md" as FilePathWithPrefix);
await started.promise;
const completion = harness.conflict.ensureAllProcessed();
await harness.unload();
pendingStore.reject(failure);
await expect(completion).resolves.toBe(false);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
});
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import PouchDB from "pouchdb-core";
import MemoryAdapter from "pouchdb-adapter-memory";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { LOG_LEVEL_VERBOSE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createConflictCheckingHandlers } from "./checker";
PouchDB.plugin(MemoryAdapter);
type Probe = { completed: number };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((finish) => {
resolve = finish;
});
return { promise, resolve };
}
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 db = new PouchDB<Probe>(name, { adapter: "memory" });
const started = deferred();
const release = deferred();
const conflict = new InjectableConflictService(createServiceContext());
const log = vi.fn();
const resolver = vi.fn(async () => {
started.resolve();
await release.promise;
const probe = await db.get("_local/shutdown-probe");
await db.put({ ...probe, completed: probe.completed + 1 });
});
conflict.resolve.setHandler(resolver);
const checking = createConflictCheckingHandlers({
conflict,
conflictProcessQueueCount: conflict.conflictProcessQueueCount,
cancelActiveResolution: vi.fn(),
currentSettings: () => ({ checkConflictOnlyOnOpen: false }),
vault: { getActiveFilePath: () => undefined },
log,
});
conflict.queueCheckFor.setHandler(checking.queueCheckFor);
let closed = false;
let reopened: PouchDB.Database<Probe> | undefined;
let completion: Promise<boolean> | undefined;
try {
await db.put({ _id: "_local/shutdown-probe", completed: 0 });
await checking.queueCheckFor("active.md" as FilePathWithPrefix);
await started.promise;
completion = checking.ensureAllProcessed();
checking.dispose();
checking.dispose();
await checking.queueCheckFor("late.md" as FilePathWithPrefix);
if (closeDuringShutdown) {
await db.close();
closed = true;
}
release.resolve();
await expect(completion).resolves.toBe(false);
expect(resolver).toHaveBeenCalledOnce();
if (closeDuringShutdown) {
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringMatching(/closed/i) }),
LOG_LEVEL_VERBOSE
);
} else {
expect(log).not.toHaveBeenCalled();
await db.close();
closed = true;
}
reopened = new PouchDB<Probe>(name, { adapter: "memory" });
const persisted = await reopened.get("_local/shutdown-probe");
expect(persisted.completed).toBe(closeDuringShutdown ? 0 : 1);
} finally {
release.resolve();
await completion?.catch(() => undefined);
if (!closed) await db.close();
await (reopened ?? new PouchDB<Probe>(name, { adapter: "memory" })).destroy();
}
}
);
});
@@ -52,7 +52,6 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
const eventSubscriptions = new AbortController();
const dispose = (): void => {
checking.dispose();
operations.dispose();
eventSubscriptions.abort();
};
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
@@ -55,8 +55,6 @@ 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,
@@ -70,21 +68,14 @@ export interface ConflictResolutionOperations {
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
}
/** Create conflict-resolution operations which stop their continuations when disposed. */
/** Create conflict-resolution operations which retain their normal completion and failure paths. */
export function createConflictResolutionOperations(
dependencies: ConflictResolutionOperationsDependencies
): ConflictResolutionOperations {
let disposed = false;
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
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);
@@ -97,11 +88,8 @@ export function createConflictResolutionOperations(
subTitle = "",
showNotice = true
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
if (disposed) return MISSING_OR_ERROR;
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
const deleted = await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision);
if (disposed) return MISSING_OR_ERROR;
if (!deleted) {
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
dependencies.log(
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
LOG_LEVEL_NOTICE
@@ -113,9 +101,7 @@ export function createConflictResolutionOperations(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
);
const conflicts = await dependencies.databaseFileAccess.getConflictedRevs(path);
if (disposed) return MISSING_OR_ERROR;
if (conflicts.length != 0) {
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
@@ -126,9 +112,7 @@ export function createConflictResolutionOperations(
// If no conflicts were found, write the resolved content to the storage.
const documentPath = parseDocumentPath(path);
const { path: filePath } = splitDocumentPath(documentPath);
const reflected = await dependencies.fileHandler.dbToStorage(documentPath, filePath, true);
if (disposed) return MISSING_OR_ERROR;
if (!reflected) {
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
@@ -138,11 +122,9 @@ export function createConflictResolutionOperations(
};
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
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;
}
@@ -150,9 +132,7 @@ export function createConflictResolutionOperations(
if ("result" in ret) {
const p = ret.result;
// 1. Store the merged content to the storage.
const stored = await dependencies.databaseFileAccess.storeContent(path, p);
if (disposed) return CANCELLED;
if (!stored) {
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
@@ -207,7 +187,6 @@ export function createConflictResolutionOperations(
};
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
if (disposed) return;
const requestId = ++nextResolveRequestId;
latestResolveRequestByFilename.set(filename, requestId);
const serialisationKey = `conflict-resolve:${filename}`;
@@ -218,12 +197,12 @@ export function createConflictResolutionOperations(
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
}
return await withLock(serialisationKey, async () => {
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
if (latestResolveRequestByFilename.get(filename) !== requestId) {
return;
}
try {
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) {
if (latestResolveRequestByFilename.get(filename) !== requestId) {
return;
}
if (conflictCheckResult === NOT_CONFLICTED) {
@@ -245,7 +224,6 @@ 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;
@@ -272,15 +250,12 @@ export function createConflictResolutionOperations(
};
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
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);
}
@@ -304,31 +279,25 @@ 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<void> => {
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(
@@ -338,12 +307,10 @@ export function createConflictResolutionOperations(
);
await resolveByNewest(file, false);
}
if (disposed) return;
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
};
return {
dispose,
cancelActiveResolution,
resolveByDeletingRevision,
checkConflictAndPerformAutoMerge,
@@ -187,6 +187,14 @@ function createDialogueConcurrencyHarness() {
}
describe("interactive conflict resolution operations", () => {
it("returns cancellation to the caller waiting for a requested conflict check", async () => {
const harness = createOperations();
harness.ensureAllProcessed.mockResolvedValue(false);
await expect(harness.operations.requestConflictResolution(path)).resolves.toBe(false);
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
});
it.each([
{ label: "cancelled", result: CANCELLED },
{ label: "postponed", result: POSTPONED },
@@ -227,14 +235,14 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not continue a selected conflict after the database read settles following disposal", async () => {
it("logs a failed database read and returns normally after 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
@@ -249,25 +257,54 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishRead(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Could not read ${path} from the local database`),
expect.anything(),
expect.anything()
);
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 () => {
it("continues a selected conflict after the database read succeeds 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 fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async () => "2-right"),
})),
});
fixture.getDBEntry.mockReturnValue(pendingRead);
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
fixture.operations.dispose();
finishRead({ _rev: "2-left", _conflicts: ["2-right"] });
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("logs 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -280,24 +317,28 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishStore(false);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
`Concatenated content cannot be stored:${path}`,
expect.anything(),
expect.anything()
);
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 () => {
it("continues a selected conflict 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -311,24 +352,26 @@ describe("interactive conflict resolution operations", () => {
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();
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("does not log a failed revision deletion after disposal", async () => {
it("logs 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -341,23 +384,27 @@ describe("interactive conflict resolution operations", () => {
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(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Merge: Something went wrong: ${path}`),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not replicate or requeue after revision deletion succeeds following disposal", async () => {
it("replicates and requeues 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -371,23 +418,25 @@ describe("interactive conflict resolution operations", () => {
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();
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("does not log or emit a conflict refresh after a pending inspection rejects following disposal", async () => {
it("logs a failed conflict inspection but does not refresh UI after 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);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -399,11 +448,20 @@ describe("interactive conflict resolution operations", () => {
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(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 2);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining(`Could not inspect the conflict state of ${path}`),
expect.anything(),
expect.anything()
);
expect(sink).toHaveBeenCalledWith(
expect.objectContaining({ message: "database unavailable" }),
expect.anything(),
expect.anything()
);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
});
@@ -414,7 +472,7 @@ describe("interactive conflict resolution operations", () => {
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -426,7 +484,6 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishInspection(["2-right"]);
await expect(refreshing).resolves.toBeUndefined();
@@ -474,7 +531,7 @@ describe("interactive conflict resolution operations", () => {
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -487,7 +544,6 @@ describe("interactive conflict resolution operations", () => {
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
emit.mockClear();
finishInspection(["2-right"]);
@@ -500,7 +556,7 @@ describe("interactive conflict resolution operations", () => {
});
it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)(
"does not continue concatenated resolution after deletion settles following disposal (%s)",
"continues concatenated resolution according to deletion result after disposal (%s)",
async (result) => {
let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
@@ -508,7 +564,7 @@ describe("interactive conflict resolution operations", () => {
});
const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink);
addLog.setHandler(sink);
const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
});
@@ -522,18 +578,31 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.storeContent).toHaveBeenCalledOnce();
const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose();
unregisterLog();
finishDeletion(result);
await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1);
if (result === MISSING_OR_ERROR) {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal + 1);
expect(sink).toHaveBeenCalledWith(
expect.stringContaining("Concatenated saved, but cannot delete conflicted revisions:"),
expect.anything(),
expect.anything()
);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
} else {
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
}
}
);
it("does not requeue after in-flight replication completes following disposal", async () => {
it("requeues after in-flight replication completes following disposal", async () => {
let finishReplication!: (result: { status: "completed" }) => void;
const pendingReplication = new Promise<{ status: "completed" }>((resolve) => {
finishReplication = resolve;
@@ -552,7 +621,7 @@ describe("interactive conflict resolution operations", () => {
await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1);
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
@@ -607,6 +676,40 @@ describe("interactive conflict resolution operations", () => {
await Promise.all([first, waiting]);
});
it("keeps another instance's active and waiting dialogues when disposal is repeated", async () => {
const oldInstance = createDialogueConcurrencyHarness();
const currentInstance = createDialogueConcurrencyHarness();
const oldActive = oldInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(oldInstance.dialogues).toHaveLength(1));
const oldWaiting = oldInstance.operations.resolveByUserInteraction(
"old-waiting.md" as FilePathWithPrefix,
conflict
);
const currentActive = currentInstance.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(1));
const currentWaiting = currentInstance.operations.resolveByUserInteraction(
"current-waiting.md" as FilePathWithPrefix,
conflict
);
await Promise.resolve();
oldInstance.operations.dispose();
oldInstance.operations.dispose();
await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]);
expect(oldInstance.dialogues).toHaveLength(1);
expect(currentInstance.dialogues).toHaveLength(1);
expect(currentInstance.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
currentInstance.dialogues[0].finish(CANCELLED);
await vi.waitFor(() => expect(currentInstance.dialogues).toHaveLength(2));
expect(currentInstance.dialogues[1].filename).toBe("current-waiting.md");
currentInstance.dialogues[1].finish(CANCELLED);
await Promise.all([currentActive, currentWaiting]);
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { constructed, operations } = createOperations();
@@ -56,7 +56,8 @@ export interface InteractiveConflictResolutionOperations {
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
readonly getActiveConflictMessages: () => Promise<string[]>;
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
/** Returns false if the requested conflict check was cancelled. */
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<boolean>;
readonly resolveByUserInteraction: (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
@@ -106,7 +107,6 @@ export function createInteractiveConflictResolutionOperations(
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;
@@ -146,11 +146,11 @@ export function createInteractiveConflictResolutionOperations(
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
};
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
postponedConflictEpisodes.delete(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await dependencies.conflict.queueCheckFor(filename);
await dependencies.conflict.ensureAllProcessed();
return await dependencies.conflict.ensureAllProcessed();
};
const resolveByUserInteraction = async (
@@ -205,7 +205,6 @@ 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;
@@ -237,7 +236,6 @@ export function createInteractiveConflictResolutionOperations(
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
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;
@@ -248,7 +246,6 @@ export function createInteractiveConflictResolutionOperations(
delRev,
"UI Concatenated"
);
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
@@ -266,7 +263,6 @@ export function createInteractiveConflictResolutionOperations(
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;
@@ -286,7 +282,6 @@ export function createInteractiveConflictResolutionOperations(
trigger: "merge",
interaction: NO_INTERACTION,
});
if (disposed) return false;
}
// And, check it again.
await dependencies.conflict.queueCheckFor(filename);
+3 -1
View File
@@ -272,7 +272,9 @@ The workflow creates a random dedicated database, records only SHA-256 Seed fing
This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. Its unload check requires the queue to report cancellation, closes active and waiting dialogues, and compares Vault contents, conflict leaves, and revision ancestry after reload.
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. It verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected current branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
@@ -58,6 +58,7 @@ type ObsidianTestApp = {
type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean;
__livesyncConflictChecksResult?: boolean;
__livesyncWaitingConflictCompleted?: boolean;
__livesyncConflictChecksError?: string;
__livesyncWaitingConflictError?: string;
@@ -76,6 +77,7 @@ async function recordConflictDiagnostics(port: number, label: string) {
pluginLoaded: plugin !== undefined,
queueCount: conflict?.conflictProcessQueueCount.value,
checksCompleted: host.__livesyncConflictChecksCompleted,
checksResult: host.__livesyncConflictChecksResult,
waitingCompleted: host.__livesyncWaitingConflictCompleted,
checksError: host.__livesyncConflictChecksError,
waitingError: host.__livesyncWaitingConflictError,
@@ -376,9 +378,11 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
host.__livesyncObservedConflict = conflict;
host.__livesyncConflictChecksCompleted = false;
host.__livesyncConflictChecksResult = undefined;
host.__livesyncConflictChecksError = undefined;
void conflict.ensureAllProcessed().then(
() => {
(result) => {
host.__livesyncConflictChecksResult = result;
host.__livesyncConflictChecksCompleted = true;
},
(error) => {
@@ -403,6 +407,8 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
undefined,
{ timeout: uiTimeoutMs }
);
const result = await page.evaluate(() => (globalThis as ObsidianTestGlobal).__livesyncConflictChecksResult);
if (result !== false) throw new Error("Unloading LiveSync did not report conflict-check cancellation");
});
}
@@ -938,7 +944,7 @@ async function main(): Promise<void> {
await waitForConflictChecks(cliBinary, session.cliEnv);
console.log(
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and drained active and waiting requests across unload and reload."
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and reported cancellation while closing active and waiting requests across unload and reload."
);
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
+146 -26
View File
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { evalObsidianJson } from "../runner/cli.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
@@ -25,12 +25,21 @@ type ShutdownObservation = {
disposeCalls: number;
disposeCompleted: boolean;
fixtureLogs: FixtureLog[];
consoleLogs: {
marker: string;
messageType: string;
messageText: string;
level?: number;
key?: string;
}[];
events: string[];
};
async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> {
return await evalObsidianJson<T>(
cliBinary,
async function evaluate<T>(session: ObsidianLiveSyncSession, body: string): Promise<T> {
// Observe the renderer console directly; CLI eval captures console output
// as part of its response and changes the logging surface under test.
return await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const result = await page.evaluate<string>(
`(async()=>{
const require=(condition,message)=>{if(!condition)throw new Error(message);};
const waitFor=async(predicate,message)=>{
@@ -41,9 +50,10 @@ async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession,
}
};
${body}
})()`,
session.cliEnv
})()`
);
return JSON.parse(result) as T;
});
}
function createPluginData(dbName: string): Record<string, unknown> {
@@ -95,7 +105,9 @@ async function startSession(
function createShutdownScript(options: {
duringMarker: string;
failureMarker: string;
afterMarker: string;
reloadMarker: string;
oldReloadMarker: string;
injectFailure: boolean;
urgentLevel: number;
}): string {
@@ -118,6 +130,8 @@ function createShutdownScript(options: {
api,
module,
fixtureLogs:[],
consoleLogs:[],
uiAddLogCalls:0,
events:[],
closeStarted:false,
closeCalls:0,
@@ -129,7 +143,39 @@ function createShutdownScript(options: {
app.__livesyncLogLifecycleState=state;
const originalAddLog=module.__addLog;
const originalConsoleLog=console.log;
const consoleMarkers=[
fixtures.duringMarker,
fixtures.failureMarker,
fixtures.afterMarker,
fixtures.reloadMarker,
fixtures.oldReloadMarker
].filter(Boolean);
console.log=function(...args) {
try {
const entry=args[0];
if(entry && typeof entry==="object" && "message" in entry) {
const message=entry.message;
const messageText=typeof message==="string"
? message
: message instanceof Error ? message.message
: message && typeof message==="object" &&
typeof message.__e2eLogLifecycleMarker==="string"
? message.__e2eLogLifecycleMarker
: "";
const marker=consoleMarkers.find(candidate=>messageText.includes(candidate));
if(marker) state.consoleLogs.push({
marker,
message,
level:entry.level,
key:entry.key
});
}
} catch {}
return originalConsoleLog.apply(this,args);
};
module.__addLog=function(message,level,key) {
state.uiAddLogCalls++;
if(typeof message==="string" && [
fixtures.duringMarker,
fixtures.failureMarker,
@@ -179,6 +225,7 @@ function createShutdownScript(options: {
module.__addLog=originalAddLog;
resource.dispose=originalDispose;
database.close=originalClose;
console.log=originalConsoleLog;
};
const run=async()=>{
@@ -203,6 +250,10 @@ function createShutdownScript(options: {
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
"The log resource was disposed before the local database close completed");
await originalDispose.call(resource);
require(state.disposeCompleted && state.disposeCalls===1,
"Repeated log resource disposal was not idempotent");
const failedLog=fixtures.failureMarker
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
: undefined;
@@ -219,13 +270,41 @@ function createShutdownScript(options: {
require(!failedLog,"The normal shutdown unexpectedly reported the injected failure");
}
let detached=false;
try {
api.addLog("e2e-log-lifecycle-after-disposal");
} catch(error) {
detached=String(error).includes("not assigned");
}
require(detached,"The old API log handler remained assigned after disposal");
const afterMessage=new Error(fixtures.afterMarker);
const afterKey="e2e-log-lifecycle-after-disposal";
const afterUnknownMessage={__e2eLogLifecycleMarker:fixtures.afterMarker};
const afterUnknownKey="e2e-log-lifecycle-after-disposal-unknown";
const uiCallsBeforeAfterLog=state.uiAddLogCalls;
api.addLog(afterMessage,fixtures.urgentLevel,afterKey);
api.addLog(afterUnknownMessage,fixtures.urgentLevel,afterUnknownKey);
await waitFor(
()=>state.consoleLogs.some(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterMessage) &&
state.consoleLogs.some(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterUnknownMessage),
"The old API did not forward its post-disposal messages to console.log"
);
const afterErrorLogs=state.consoleLogs.filter(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterMessage);
const afterUnknownLogs=state.consoleLogs.filter(entry=>entry.marker===fixtures.afterMarker &&
entry.message===afterUnknownMessage);
require(afterErrorLogs.length===1,"The post-disposal Error was not logged exactly once");
require(afterUnknownLogs.length===1,"The post-disposal unknown message was not logged exactly once");
require(
afterErrorLogs[0].message===afterMessage && afterErrorLogs[0].message instanceof Error &&
afterErrorLogs[0].message.message===fixtures.afterMarker,
"The post-disposal Error was not preserved in the console log"
);
require(afterUnknownLogs[0].message===afterUnknownMessage,
"The post-disposal unknown message was not preserved in the console log");
require(
afterErrorLogs[0].level===fixtures.urgentLevel && afterErrorLogs[0].key===afterKey &&
afterUnknownLogs[0].level===fixtures.urgentLevel &&
afterUnknownLogs[0].key===afterUnknownKey,
"The post-disposal console logs changed their level or key"
);
require(state.uiAddLogCalls===uiCallsBeforeAfterLog,
"The disposed ModuleLog UI handler received a post-disposal log");
await state.disablePromise;
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
return JSON.stringify({
@@ -233,6 +312,19 @@ function createShutdownScript(options: {
disposeCalls:state.disposeCalls,
disposeCompleted:state.disposeCompleted,
fixtureLogs:state.fixtureLogs,
consoleLogs:state.consoleLogs.map(entry=>({
marker:entry.marker,
messageType:entry.message instanceof Error ? "Error" : typeof entry.message,
messageText:entry.message instanceof Error
? entry.message.message
: typeof entry.message==="string" ? entry.message
: entry.message && typeof entry.message==="object" &&
typeof entry.message.__e2eLogLifecycleMarker==="string"
? entry.message.__e2eLogLifecycleMarker
: "",
level:entry.level,
key:entry.key
})),
events:state.events
});
};
@@ -260,10 +352,14 @@ async function runCase(options: {
let session: ObsidianLiveSyncSession | undefined;
const markers = {
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(),
failureMarker: options.injectFailure
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
: "",
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`,
oldReloadMarker: options.injectFailure
? ""
: "log-lifecycle-" + options.caseName + "-old-api-after-reload-" + randomUUID(),
injectFailure: options.injectFailure,
urgentLevel: LOG_LEVEL_URGENT,
};
@@ -271,19 +367,17 @@ async function runCase(options: {
try {
session = await startSession(options.binary, options.cliBinary, vault);
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
const shutdown = await evaluate<ShutdownObservation>(options.cliBinary, session, createShutdownScript(markers));
const shutdown = await evaluate<ShutdownObservation>(session, createShutdownScript(markers));
if (options.injectFailure) {
return { caseName: options.caseName, shutdown };
}
await evaluate(
options.cliBinary,
session,
`await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});`
);
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
const reload = await evaluate<Record<string, unknown>>(
options.cliBinary,
session,
`
const state=app.__livesyncLogLifecycleState;
@@ -295,8 +389,12 @@ async function runCase(options: {
require(module,"The reloaded core did not contain ModuleLog");
const originalAddLog=module.__addLog;
const observed=[];
const oldApiKey="e2e-log-lifecycle-reloaded-old-api";
module.__addLog=function(message,level,key) {
if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) {
if(key===oldApiKey || (typeof message==="string" && [
${JSON.stringify(markers.reloadMarker)},
${JSON.stringify(markers.oldReloadMarker)}
].some(marker=>marker && message.includes(marker)))) {
observed.push({message,level,key});
}
return originalAddLog.apply(this,arguments);
@@ -309,16 +407,36 @@ async function runCase(options: {
!state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})),
"The old ModuleLog observer received the reloaded core's fixture log"
);
let oldHandlerDetached=false;
try {
state.api.addLog("e2e-log-lifecycle-reloaded-old-api");
} catch(error) {
oldHandlerDetached=String(error).includes("not assigned");
}
require(oldHandlerDetached,"The old API log handler was reassigned after reload");
const oldApiMarker=${JSON.stringify(markers.oldReloadMarker)};
const oldApiMessage=new Error(oldApiMarker);
const uiCallsBeforeOldApi=state.uiAddLogCalls;
const newModuleFixtureCallsBeforeOldApi=observed.length;
state.api.addLog(oldApiMessage,${JSON.stringify(markers.urgentLevel)},oldApiKey);
await waitFor(
()=>state.consoleLogs.some(entry=>entry.marker===oldApiMarker &&
entry.message===oldApiMessage),
"The old API did not forward its post-reload Error to console.log"
);
const oldApiLogs=state.consoleLogs.filter(entry=>entry.marker===oldApiMarker);
require(oldApiLogs.length===1,"The old API post-reload Error was not logged exactly once");
require(
oldApiLogs[0].message===oldApiMessage && oldApiLogs[0].message instanceof Error &&
oldApiLogs[0].message.message===oldApiMarker,
"The old API post-reload Error was not preserved in the console log"
);
require(
oldApiLogs[0].level===${JSON.stringify(markers.urgentLevel)} &&
oldApiLogs[0].key===oldApiKey,
"The old API post-reload console log changed its level or key"
);
require(state.uiAddLogCalls===uiCallsBeforeOldApi,
"The old ModuleLog UI handler received the old API's post-reload log");
require(observed.length===newModuleFixtureCallsBeforeOldApi,
"The new ModuleLog received the old API's post-reload log");
return JSON.stringify({
coreChanged:core!==state.core,
observedByNewModule:observed.length,
oldApiConsoleLogs:oldApiLogs.length,
observedByOldModule:state.fixtureLogs.filter(
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
).length
@@ -335,13 +453,15 @@ async function runCase(options: {
if (session) {
try {
await evaluate(
options.cliBinary,
session,
`
const state=app.__livesyncLogLifecycleState;
try {
return JSON.stringify({cleaned:true});
} finally {
state?.restore?.();
delete app.__livesyncLogLifecycleState;
return JSON.stringify({cleaned:true});
}
`
);
} catch {}
+2 -1
View File
@@ -20,7 +20,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Conflict resolution
- Pending conflict dialogues and their resolution steps stop using the released logger after the plug-in is disabled. Waiting continuations check for shutdown before logging or starting further resolution work.
- Disabling or reloading the plug-in no longer causes a secondary error when pending work writes to the log. Logs arriving after the log display closes go to Obsidian's console.
- Open conflict dialogues close immediately, and queued checks report cancellation. Operations already running retain their normal completion or failure path.
#### WebApp