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
+640 -221

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. // Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]); const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries)); setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
@@ -126,7 +130,7 @@ function redactLog(log: string) {
const showDebugLog = false; const showDebugLog = false;
export const MARK_DONE = "\u{2009}\u{2009}"; export const MARK_DONE = "\u{2009}\u{2009}";
export class ModuleLog extends AbstractObsidianModule { 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>; logResource!: OwnedResource<LogFunction>;
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]); private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
private readonly displayLogEntries = () => { private readonly displayLogEntries = () => {
@@ -615,8 +619,10 @@ ${stringifyYaml(info)}
} }
} }
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
const log = createBufferedLog(this.recentLogEntries); const bufferedLog = createBufferedLog(this.recentLogEntries);
const unregisterLog = services.API.addLog.setHandler(log); let logTarget: LogFunction = bufferedLog;
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
services.API.addLog.setHandler(log);
const collectGlobalLogs = () => { const collectGlobalLogs = () => {
const entries = recentGlobalLogEntries.value; const entries = recentGlobalLogEntries.value;
if (entries.length === 0) return; if (entries.length === 0) return;
@@ -624,7 +630,7 @@ ${stringifyYaml(info)}
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries]; this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
}; };
this.logResource = createOwnedResource(log, () => { this.logResource = createOwnedResource(log, () => {
unregisterLog(); logTarget = logToConsole;
recentGlobalLogEntries.offChanged(collectGlobalLogs); recentGlobalLogEntries.offChanged(collectGlobalLogs);
this.recentLogEntries.offChanged(this.displayLogEntries); this.recentLogEntries.offChanged(this.displayLogEntries);
this.recentLogEntries.value = []; this.recentLogEntries.value = [];
+115 -27
View File
@@ -107,25 +107,92 @@ 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 first = await createHarness();
const second = await createHarness(); const second = await createHarness();
await first.services.appLifecycle.onSettingLoaded(); await first.services.appLifecycle.onSettingLoaded();
await second.services.appLifecycle.onSettingLoaded(); await second.services.appLifecycle.onSettingLoaded();
first.sink.mockClear(); first.sink.mockClear();
second.sink.mockClear(); second.sink.mockClear();
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
await first.module.logResource.dispose(); try {
await first.module.logResource.dispose(); await first.module.logResource.dispose();
expect(() => first.services.API.addLog("retired")).toThrow("not assigned"); await first.module.logResource.dispose();
second.services.API.addLog("active", 32, "service"); expect(() => first.services.API.addLog("retired", 32, "retired-key")).not.toThrow();
first.Logger("legacy-after-retirement", 32, "legacy"); expect(consoleLog).toHaveBeenCalledWith({
expect(first.sink).not.toHaveBeenCalled(); message: "retired",
expect(second.sink.mock.calls).toEqual([ level: 32,
["active", 32, "service"], key: "retired-key",
["legacy-after-retirement", 32, "legacy"], });
]); second.services.API.addLog("active", 32, "service");
await second.module.logResource.dispose(); first.Logger("legacy-after-retirement", 32, "legacy");
expect(first.sink).not.toHaveBeenCalled();
expect(second.sink.mock.calls).toEqual([
["active", 32, "service"],
["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 () => { 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(); 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(); const { services, module, sink, Logger } = await createHarness();
services.API.addLog("pending"); services.API.addLog("pending");
await module.logResource.dispose(); const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
Logger("after-disposal"); try {
expect(sink).not.toHaveBeenCalled(); await module.logResource.dispose();
expect(() => services.API.addLog("retired")).toThrow("not assigned"); 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();
const replacement = await createHarness(); const replacement = await createHarness();
await replacement.services.appLifecycle.onSettingLoaded(); await replacement.services.appLifecycle.onSettingLoaded();
const messages = replacement.sink.mock.calls.map(([message]) => message); const messages = replacement.sink.mock.calls.map(([message]) => message);
expect(messages).toContain("after-disposal"); expect(messages).toContain("after-disposal");
expect(messages).not.toContain("pending"); expect(messages).not.toContain("pending");
await replacement.module.logResource.dispose(); expect(messages).not.toContain("retired");
await replacement.module.logResource.dispose();
} finally {
consoleLog.mockRestore();
}
}); });
it.each(["complete", "fail"] as const)( it.each(["complete", "fail"] as const)(
@@ -219,10 +297,20 @@ describe("ModuleLog service connection", () => {
"moduleLiveSyncMain.logUnloadingPlugin" "moduleLiveSyncMain.logUnloadingPlugin"
); );
} }
expect(() => services.API.addLog("retired")).toThrow("not assigned"); const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
sink.mockClear(); try {
Logger("after-unload"); expect(() => services.API.addLog("late-after-close", 32, "late-after-close")).not.toThrow();
expect(sink).not.toHaveBeenCalled(); 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 { 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 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>;
/** Wait for active checks; false means the queue was cancelled during shutdown. */
readonly ensureAllProcessed: () => Promise<boolean>; readonly ensureAllProcessed: () => Promise<boolean>;
} }
@@ -37,11 +38,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
let disposed = false; 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);
} }
@@ -89,7 +88,11 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle(); const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
const dispose = (): void => { const dispose = (): void => {
if (disposed) return;
disposed = true; 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 { return {
@@ -940,7 +940,7 @@ 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 () => { it("reports queue cancellation and logs a resolver rejection after unload", async () => {
const path = "queued-rejection-after-unload.md" as FilePathWithPrefix; const path = "queued-rejection-after-unload.md" as FilePathWithPrefix;
const harness = createHarness(); const harness = createHarness();
const started = deferred<void>(); const started = deferred<void>();
@@ -959,17 +959,16 @@ describe("conflict resolution serviceFeature", () => {
); );
await harness.unload(); await harness.unload();
harness.addLog.mockImplementation(() => { await harness.unload();
throw new Error("Handler addLog not assigned"); const failure = new Error("Resolver rejected after unload");
}); pendingResolve.reject(failure);
pendingResolve.reject(new Error("Resolver rejected after unload"));
await expect(completion).resolves.toEqual({ status: "resolved", result: true }); await expect(completion).resolves.toEqual({ status: "resolved", result: false });
expect(resolve).toHaveBeenCalledOnce(); 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 path = "not-conflicted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness(); const harness = createHarness();
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
@@ -989,18 +988,15 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true); expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingMergeCheck.resolve({ ok: NOT_CONFLICTED }); pendingMergeCheck.resolve({ ok: NOT_CONFLICTED });
await expect(completion).resolves.toEqual({ status: "resolved" }); 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(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 path = "stored-merge-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } }); const harness = createHarness({ settings: { syncAfterMerge: false } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
@@ -1021,19 +1017,18 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n"); expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged content\n");
await harness.unload(); await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingStore.resolve(true); pendingStore.resolve(true);
await expect(completion).resolves.toEqual({ status: "resolved" }); await expect(completion).resolves.toEqual({ status: "resolved" });
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled(); expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
expect(queueCheckFor).not.toHaveBeenCalled(); expect(harness.fileHandler.dbToStorage).toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.addLog).not.toHaveBeenCalled(); 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 path = "deleted-after-unload.md" as FilePathWithPrefix;
const harness = createHarness(); const harness = createHarness();
const started = deferred<void>(); const started = deferred<void>();
@@ -1052,19 +1047,16 @@ describe("conflict resolution serviceFeature", () => {
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict"); expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-conflict");
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingDelete.resolve(true); pendingDelete.resolve(true);
await expect(completion).resolves.toMatchObject({ status: "resolved" }); await expect(completion).resolves.toEqual({ status: "resolved", result: AUTO_MERGED });
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled(); expect(harness.databaseFileAccess.getConflictedRevs).toHaveBeenCalledWith(path);
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled(); expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); expect(emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).not.toHaveBeenCalled(); 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 path = "replication-after-unload.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: true } }); const harness = createHarness({ settings: { syncAfterMerge: true } });
const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor"); const queueCheckFor = vi.spyOn(harness.conflict, "queueCheckFor");
@@ -1087,14 +1079,13 @@ describe("conflict resolution serviceFeature", () => {
}); });
await harness.unload(); await harness.unload();
harness.addLog.mockImplementation(() => {
throw new Error("Handler addLog not assigned");
});
pendingReplication.resolve({ status: "completed" }); pendingReplication.resolve({ status: "completed" });
await expect(completion).resolves.toEqual({ status: "resolved" }); await expect(completion).resolves.toEqual({ status: "resolved" });
expect(queueCheckFor).not.toHaveBeenCalled(); expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.addLog).not.toHaveBeenCalled(); 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 () => { 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)( 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) => { async (trigger) => {
const path = "operation-after-unload.md" as FilePathWithPrefix; const path = "operation-after-unload.md" as FilePathWithPrefix;
const harness = createHarness(); const harness = createHarness();
@@ -1154,9 +1145,10 @@ describe("conflict resolution serviceFeature", () => {
} else { } else {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
} }
harness.addLog.mockImplementation(() => { const failure = new Error("database closed");
throw new Error("Handler addLog not assigned"); harness.tryAutoMerge.mockRejectedValue(failure);
}); harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
try { try {
const results = await Promise.allSettled([ const results = await Promise.allSettled([
@@ -1168,26 +1160,34 @@ describe("conflict resolution serviceFeature", () => {
harness.conflict.resolveAllConflictedFilesByNewerOnes(), 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(); for (const operation of publicOperations) expect(operation).toHaveBeenCalledOnce();
expect(optionalCheck).not.toHaveBeenCalled(); expect(optionalCheck).not.toHaveBeenCalled();
expect(harness.tryAutoMerge).not.toHaveBeenCalled(); expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled(); expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledOnce();
expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled(); expect(harness.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.databaseFileAccess.storeContent).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.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.storageAccess.getFileNames).not.toHaveBeenCalled(); expect(harness.storageAccess.getFileNames).toHaveBeenCalledOnce();
expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled(); expect(harness.services.replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path); expect(emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(harness.addLog).not.toHaveBeenCalled(); expect(harness.addLog).toHaveBeenCalled();
} finally { } finally {
unregisterOptionalCheck(); 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 paths = Array.from({ length: 11 }, (_, index) => `unload-concurrent-${index}.md` as FilePathWithPrefix);
const harness = createHarness(); const harness = createHarness();
const tenStarted = deferred<void>(); const tenStarted = deferred<void>();
@@ -1206,12 +1206,12 @@ describe("conflict resolution serviceFeature", () => {
harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED); harness.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
releaseResolvers.resolve(undefined); releaseResolvers.resolve(undefined);
await expect(completion).resolves.toBe(true); await expect(completion).resolves.toBe(false);
expect(resolve).toHaveBeenCalledTimes(10); 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 path = "bulk-resolution-after-unload.md" as FilePathWithPrefix;
const harness = createHarness(); const harness = createHarness();
const namesStarted = deferred<void>(); const namesStarted = deferred<void>();
@@ -1227,17 +1227,58 @@ describe("conflict resolution serviceFeature", () => {
harness.addLog.mockClear(); harness.addLog.mockClear();
await harness.unload(); await harness.unload();
harness.addLog.mockImplementation(() => { harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
throw new Error("Handler addLog not assigned");
});
pendingNames.resolve([path]); pendingNames.resolve([path]);
await expect(operation).resolves.toBeUndefined(); 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.databaseFileAccess.getConflictedRevs).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled(); expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).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 () => { 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 eventSubscriptions = new AbortController();
const dispose = (): void => { const dispose = (): void => {
checking.dispose(); checking.dispose();
operations.dispose();
eventSubscriptions.abort(); eventSubscriptions.abort();
}; };
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal }); services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
@@ -55,8 +55,6 @@ 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,
@@ -70,21 +68,14 @@ export interface ConflictResolutionOperations {
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>; 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( 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);
@@ -97,11 +88,8 @@ 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}]` : ""}:`;
const deleted = await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision); if (!(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
@@ -113,9 +101,7 @@ 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
); );
const conflicts = await dependencies.databaseFileAccess.getConflictedRevs(path); if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
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;
} }
@@ -126,9 +112,7 @@ 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);
const reflected = await dependencies.fileHandler.dbToStorage(documentPath, filePath, true); if (!(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;
} }
@@ -138,11 +122,9 @@ 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;
} }
@@ -150,9 +132,7 @@ 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.
const stored = await dependencies.databaseFileAccess.storeContent(path, p); if (!(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;
} }
@@ -207,7 +187,6 @@ 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}`;
@@ -218,12 +197,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 (disposed || latestResolveRequestByFilename.get(filename) !== requestId) { if (latestResolveRequestByFilename.get(filename) !== requestId) {
return; return;
} }
try { try {
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename); const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
if (disposed || latestResolveRequestByFilename.get(filename) !== requestId) { if (latestResolveRequestByFilename.get(filename) !== requestId) {
return; return;
} }
if (conflictCheckResult === NOT_CONFLICTED) { if (conflictCheckResult === NOT_CONFLICTED) {
@@ -245,7 +224,6 @@ 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;
@@ -272,15 +250,12 @@ 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);
} }
@@ -304,31 +279,25 @@ 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(
@@ -338,12 +307,10 @@ 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,
@@ -187,6 +187,14 @@ function createDialogueConcurrencyHarness() {
} }
describe("interactive conflict resolution operations", () => { 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([ it.each([
{ label: "cancelled", result: CANCELLED }, { label: "cancelled", result: CANCELLED },
{ label: "postponed", result: POSTPONED }, { label: "postponed", result: POSTPONED },
@@ -227,14 +235,14 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.queueCheckFor).not.toHaveBeenCalled(); 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; let finishRead!: (value: false | { _rev: string; _conflicts?: string[] }) => void;
const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => { const pendingRead = new Promise<false | { _rev: string; _conflicts?: string[] }>((resolve) => {
finishRead = resolve; finishRead = resolve;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
createDialogue: vi.fn(() => ({ createDialogue: vi.fn(() => ({
open: vi.fn(), open: vi.fn(),
@@ -249,25 +257,54 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishRead(false); finishRead(false);
await expect(resolving).resolves.toBe(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.storeContent).not.toHaveBeenCalled();
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).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; let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => { const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve; finishStore = resolve;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -280,24 +317,28 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishStore(false); finishStore(false);
await expect(resolving).resolves.toBe(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.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).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; let finishStore!: (stored: boolean) => void;
const pendingStore = new Promise<boolean>((resolve) => { const pendingStore = new Promise<boolean>((resolve) => {
finishStore = resolve; finishStore = resolve;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -311,24 +352,26 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishStore(true); finishStore(true);
await expect(resolving).resolves.toBe(false); await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled(); expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
expect(fixture.queueCheckFor).not.toHaveBeenCalled(); 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; let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => { const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve; finishDeletion = resolve;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -341,23 +384,27 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishDeletion(MISSING_OR_ERROR); finishDeletion(MISSING_OR_ERROR);
await expect(resolving).resolves.toBe(false); 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.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).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; let finishDeletion!: (result: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => { const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => {
finishDeletion = resolve; finishDeletion = resolve;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -371,23 +418,25 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishDeletion(AUTO_MERGED); finishDeletion(AUTO_MERGED);
await expect(resolving).resolves.toBe(false); await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal); expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
expect(fixture.queueCheckFor).not.toHaveBeenCalled(); 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; let rejectInspection!: (reason: unknown) => void;
const pendingInspection = new Promise<string[]>((_resolve, reject) => { const pendingInspection = new Promise<string[]>((_resolve, reject) => {
rejectInspection = reject; rejectInspection = reject;
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -399,11 +448,20 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
rejectInspection(new Error("database unavailable")); rejectInspection(new Error("database unavailable"));
await expect(refreshing).resolves.toBeUndefined(); 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); expect(emitEvent).not.toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
}); });
@@ -414,7 +472,7 @@ describe("interactive conflict resolution operations", () => {
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -426,7 +484,6 @@ describe("interactive conflict resolution operations", () => {
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishInspection(["2-right"]); finishInspection(["2-right"]);
await expect(refreshing).resolves.toBeUndefined(); await expect(refreshing).resolves.toBeUndefined();
@@ -474,7 +531,7 @@ describe("interactive conflict resolution operations", () => {
}); });
const sink = vi.fn(); const sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -487,7 +544,6 @@ describe("interactive conflict resolution operations", () => {
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
emit.mockClear(); emit.mockClear();
finishInspection(["2-right"]); finishInspection(["2-right"]);
@@ -500,7 +556,7 @@ describe("interactive conflict resolution operations", () => {
}); });
it.each([AUTO_MERGED, MISSING_OR_ERROR] as const)( 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) => { async (result) => {
let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void; let finishDeletion!: (value: typeof AUTO_MERGED | typeof MISSING_OR_ERROR) => void;
const pendingDeletion = new Promise<typeof AUTO_MERGED | typeof MISSING_OR_ERROR>((resolve) => { 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 sink = vi.fn();
const addLog = bindableFunction<(message: unknown) => void>("addLog"); const addLog = bindableFunction<(message: unknown) => void>("addLog");
const unregisterLog = addLog.setHandler(sink); addLog.setHandler(sink);
const fixture = createOperations(undefined, { const fixture = createOperations(undefined, {
log: createInstanceLogFunction("Conflict shutdown", { addLog } as never), log: createInstanceLogFunction("Conflict shutdown", { addLog } as never),
}); });
@@ -522,18 +578,31 @@ describe("interactive conflict resolution operations", () => {
expect(fixture.storeContent).toHaveBeenCalledOnce(); expect(fixture.storeContent).toHaveBeenCalledOnce();
const logsBeforeDisposal = sink.mock.calls.length; const logsBeforeDisposal = sink.mock.calls.length;
fixture.operations.dispose(); fixture.operations.dispose();
unregisterLog();
finishDeletion(result); finishDeletion(result);
await expect(resolving).resolves.toBe(false); await expect(resolving).resolves.toBe(false);
expect(sink).toHaveBeenCalledTimes(logsBeforeDisposal);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1); expect(fixture.resolveByDeletingRevision).toHaveBeenCalledTimes(1);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled(); if (result === MISSING_OR_ERROR) {
expect(fixture.queueCheckFor).not.toHaveBeenCalled(); 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; let finishReplication!: (result: { status: "completed" }) => void;
const pendingReplication = new Promise<{ status: "completed" }>((resolve) => { const pendingReplication = new Promise<{ status: "completed" }>((resolve) => {
finishReplication = resolve; finishReplication = resolve;
@@ -552,7 +621,7 @@ describe("interactive conflict resolution operations", () => {
await expect(resolving).resolves.toBe(false); await expect(resolving).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce(); expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledTimes(1); 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 () => { 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]); 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 () => { it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { constructed, operations } = createOperations(); const { constructed, operations } = createOperations();
@@ -56,7 +56,8 @@ export interface InteractiveConflictResolutionOperations {
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void; readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
readonly getActiveConflictMessages: () => Promise<string[]>; readonly getActiveConflictMessages: () => Promise<string[]>;
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>; 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: ( readonly resolveByUserInteraction: (
filename: FilePathWithPrefix, filename: FilePathWithPrefix,
conflictCheckResult: diff_result conflictCheckResult: diff_result
@@ -106,7 +107,6 @@ export function createInteractiveConflictResolutionOperations(
if (disposed) return undefined; if (disposed) return undefined;
return conflictCount === 0 ? 0 : conflictCount + 1; return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) { } catch (error) {
if (disposed) return undefined;
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE); dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.log(error, LOG_LEVEL_VERBOSE); dependencies.log(error, LOG_LEVEL_VERBOSE);
return undefined; return undefined;
@@ -146,11 +146,11 @@ export function createInteractiveConflictResolutionOperations(
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}; };
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => { const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
postponedConflictEpisodes.delete(filename); postponedConflictEpisodes.delete(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR); dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await dependencies.conflict.queueCheckFor(filename); await dependencies.conflict.queueCheckFor(filename);
await dependencies.conflict.ensureAllProcessed(); return await dependencies.conflict.ensureAllProcessed();
}; };
const resolveByUserInteraction = async ( const resolveByUserInteraction = async (
@@ -205,7 +205,6 @@ export function createInteractiveConflictResolutionOperations(
const testDoc = await dependencies const testDoc = await dependencies
.localDatabase() .localDatabase()
.getDBEntry(filename, { conflicts: true }, false, true, true); .getDBEntry(filename, { conflicts: true }, false, true, true);
if (disposed) return false;
if (testDoc === false) { if (testDoc === false) {
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE); dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false; return false;
@@ -237,7 +236,6 @@ export function createInteractiveConflictResolutionOperations(
const p = conflictCheckResult.diff.map((e) => e[1]).join(""); const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev; const delRev = conflictCheckResult.right.rev;
const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p); const contentStored = await dependencies.databaseFileAccess.storeContent(filename, p);
if (disposed) return false;
if (!contentStored) { if (!contentStored) {
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE); dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false; return false;
@@ -248,7 +246,6 @@ export function createInteractiveConflictResolutionOperations(
delRev, delRev,
"UI Concatenated" "UI Concatenated"
); );
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) { if (deletionResult == MISSING_OR_ERROR) {
dependencies.log( dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`, `Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
@@ -266,7 +263,6 @@ export function createInteractiveConflictResolutionOperations(
toDelete, toDelete,
"UI Selected" "UI Selected"
); );
if (disposed) return false;
if (deletionResult == MISSING_OR_ERROR) { if (deletionResult == MISSING_OR_ERROR) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE); dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false; return false;
@@ -286,7 +282,6 @@ export function createInteractiveConflictResolutionOperations(
trigger: "merge", trigger: "merge",
interaction: NO_INTERACTION, interaction: NO_INTERACTION,
}); });
if (disposed) return false;
} }
// And, check it again. // And, check it again.
await dependencies.conflict.queueCheckFor(filename); 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. 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. `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 & { type ObsidianTestGlobal = typeof globalThis & {
app?: ObsidianTestApp; app?: ObsidianTestApp;
__livesyncConflictChecksCompleted?: boolean; __livesyncConflictChecksCompleted?: boolean;
__livesyncConflictChecksResult?: boolean;
__livesyncWaitingConflictCompleted?: boolean; __livesyncWaitingConflictCompleted?: boolean;
__livesyncConflictChecksError?: string; __livesyncConflictChecksError?: string;
__livesyncWaitingConflictError?: string; __livesyncWaitingConflictError?: string;
@@ -76,6 +77,7 @@ async function recordConflictDiagnostics(port: number, label: string) {
pluginLoaded: plugin !== undefined, pluginLoaded: plugin !== undefined,
queueCount: conflict?.conflictProcessQueueCount.value, queueCount: conflict?.conflictProcessQueueCount.value,
checksCompleted: host.__livesyncConflictChecksCompleted, checksCompleted: host.__livesyncConflictChecksCompleted,
checksResult: host.__livesyncConflictChecksResult,
waitingCompleted: host.__livesyncWaitingConflictCompleted, waitingCompleted: host.__livesyncWaitingConflictCompleted,
checksError: host.__livesyncConflictChecksError, checksError: host.__livesyncConflictChecksError,
waitingError: host.__livesyncWaitingConflictError, 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"); if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
host.__livesyncObservedConflict = conflict; host.__livesyncObservedConflict = conflict;
host.__livesyncConflictChecksCompleted = false; host.__livesyncConflictChecksCompleted = false;
host.__livesyncConflictChecksResult = undefined;
host.__livesyncConflictChecksError = undefined; host.__livesyncConflictChecksError = undefined;
void conflict.ensureAllProcessed().then( void conflict.ensureAllProcessed().then(
() => { (result) => {
host.__livesyncConflictChecksResult = result;
host.__livesyncConflictChecksCompleted = true; host.__livesyncConflictChecksCompleted = true;
}, },
(error) => { (error) => {
@@ -403,6 +407,8 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
undefined, undefined,
{ timeout: uiTimeoutMs } { 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); await waitForConflictChecks(cliBinary, session.cliEnv);
console.log( 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(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`); console.log(`Postponed warning screenshot: ${warningScreenshot}`);
+159 -39
View File
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger"; 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 { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import { import {
createE2eCouchDbPluginData, createE2eCouchDbPluginData,
@@ -25,25 +25,35 @@ type ShutdownObservation = {
disposeCalls: number; disposeCalls: number;
disposeCompleted: boolean; disposeCompleted: boolean;
fixtureLogs: FixtureLog[]; fixtureLogs: FixtureLog[];
consoleLogs: {
marker: string;
messageType: string;
messageText: string;
level?: number;
key?: string;
}[];
events: string[]; events: string[];
}; };
async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> { async function evaluate<T>(session: ObsidianLiveSyncSession, body: string): Promise<T> {
return await evalObsidianJson<T>( // Observe the renderer console directly; CLI eval captures console output
cliBinary, // as part of its response and changes the logging surface under test.
`(async()=>{ return await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const require=(condition,message)=>{if(!condition)throw new Error(message);}; const result = await page.evaluate<string>(
const waitFor=async(predicate,message)=>{ `(async()=>{
const until=Date.now()+15000; const require=(condition,message)=>{if(!condition)throw new Error(message);};
while(!predicate()) { const waitFor=async(predicate,message)=>{
if(Date.now()>until)throw new Error(message); const until=Date.now()+15000;
await new Promise(resolve=>setTimeout(resolve,20)); while(!predicate()) {
} if(Date.now()>until)throw new Error(message);
}; await new Promise(resolve=>setTimeout(resolve,20));
${body} }
})()`, };
session.cliEnv ${body}
); })()`
);
return JSON.parse(result) as T;
});
} }
function createPluginData(dbName: string): Record<string, unknown> { function createPluginData(dbName: string): Record<string, unknown> {
@@ -95,7 +105,9 @@ async function startSession(
function createShutdownScript(options: { function createShutdownScript(options: {
duringMarker: string; duringMarker: string;
failureMarker: string; failureMarker: string;
afterMarker: string;
reloadMarker: string; reloadMarker: string;
oldReloadMarker: string;
injectFailure: boolean; injectFailure: boolean;
urgentLevel: number; urgentLevel: number;
}): string { }): string {
@@ -118,6 +130,8 @@ function createShutdownScript(options: {
api, api,
module, module,
fixtureLogs:[], fixtureLogs:[],
consoleLogs:[],
uiAddLogCalls:0,
events:[], events:[],
closeStarted:false, closeStarted:false,
closeCalls:0, closeCalls:0,
@@ -129,7 +143,39 @@ function createShutdownScript(options: {
app.__livesyncLogLifecycleState=state; app.__livesyncLogLifecycleState=state;
const originalAddLog=module.__addLog; 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) { module.__addLog=function(message,level,key) {
state.uiAddLogCalls++;
if(typeof message==="string" && [ if(typeof message==="string" && [
fixtures.duringMarker, fixtures.duringMarker,
fixtures.failureMarker, fixtures.failureMarker,
@@ -179,6 +225,7 @@ function createShutdownScript(options: {
module.__addLog=originalAddLog; module.__addLog=originalAddLog;
resource.dispose=originalDispose; resource.dispose=originalDispose;
database.close=originalClose; database.close=originalClose;
console.log=originalConsoleLog;
}; };
const run=async()=>{ const run=async()=>{
@@ -203,6 +250,10 @@ function createShutdownScript(options: {
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"), state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
"The log resource was disposed before the local database close completed"); "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 const failedLog=fixtures.failureMarker
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker)) ? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
: undefined; : undefined;
@@ -219,13 +270,41 @@ function createShutdownScript(options: {
require(!failedLog,"The normal shutdown unexpectedly reported the injected failure"); require(!failedLog,"The normal shutdown unexpectedly reported the injected failure");
} }
let detached=false; const afterMessage=new Error(fixtures.afterMarker);
try { const afterKey="e2e-log-lifecycle-after-disposal";
api.addLog("e2e-log-lifecycle-after-disposal"); const afterUnknownMessage={__e2eLogLifecycleMarker:fixtures.afterMarker};
} catch(error) { const afterUnknownKey="e2e-log-lifecycle-after-disposal-unknown";
detached=String(error).includes("not assigned"); const uiCallsBeforeAfterLog=state.uiAddLogCalls;
} api.addLog(afterMessage,fixtures.urgentLevel,afterKey);
require(detached,"The old API log handler remained assigned after disposal"); 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; await state.disablePromise;
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError); require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
return JSON.stringify({ return JSON.stringify({
@@ -233,6 +312,19 @@ function createShutdownScript(options: {
disposeCalls:state.disposeCalls, disposeCalls:state.disposeCalls,
disposeCompleted:state.disposeCompleted, disposeCompleted:state.disposeCompleted,
fixtureLogs:state.fixtureLogs, 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 events:state.events
}); });
}; };
@@ -260,10 +352,14 @@ async function runCase(options: {
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
const markers = { const markers = {
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`, duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(),
failureMarker: options.injectFailure failureMarker: options.injectFailure
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}` ? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
: "", : "",
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${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, injectFailure: options.injectFailure,
urgentLevel: LOG_LEVEL_URGENT, urgentLevel: LOG_LEVEL_URGENT,
}; };
@@ -271,19 +367,17 @@ async function runCase(options: {
try { try {
session = await startSession(options.binary, options.cliBinary, vault); session = await startSession(options.binary, options.cliBinary, vault);
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv); 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) { if (options.injectFailure) {
return { caseName: options.caseName, shutdown }; return { caseName: options.caseName, shutdown };
} }
await evaluate( await evaluate(
options.cliBinary,
session, session,
`await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});` `await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});`
); );
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv); await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
const reload = await evaluate<Record<string, unknown>>( const reload = await evaluate<Record<string, unknown>>(
options.cliBinary,
session, session,
` `
const state=app.__livesyncLogLifecycleState; const state=app.__livesyncLogLifecycleState;
@@ -295,8 +389,12 @@ async function runCase(options: {
require(module,"The reloaded core did not contain ModuleLog"); require(module,"The reloaded core did not contain ModuleLog");
const originalAddLog=module.__addLog; const originalAddLog=module.__addLog;
const observed=[]; const observed=[];
const oldApiKey="e2e-log-lifecycle-reloaded-old-api";
module.__addLog=function(message,level,key) { 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}); observed.push({message,level,key});
} }
return originalAddLog.apply(this,arguments); return originalAddLog.apply(this,arguments);
@@ -309,16 +407,36 @@ async function runCase(options: {
!state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})), !state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})),
"The old ModuleLog observer received the reloaded core's fixture log" "The old ModuleLog observer received the reloaded core's fixture log"
); );
let oldHandlerDetached=false; const oldApiMarker=${JSON.stringify(markers.oldReloadMarker)};
try { const oldApiMessage=new Error(oldApiMarker);
state.api.addLog("e2e-log-lifecycle-reloaded-old-api"); const uiCallsBeforeOldApi=state.uiAddLogCalls;
} catch(error) { const newModuleFixtureCallsBeforeOldApi=observed.length;
oldHandlerDetached=String(error).includes("not assigned"); state.api.addLog(oldApiMessage,${JSON.stringify(markers.urgentLevel)},oldApiKey);
} await waitFor(
require(oldHandlerDetached,"The old API log handler was reassigned after reload"); ()=>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({ return JSON.stringify({
coreChanged:core!==state.core, coreChanged:core!==state.core,
observedByNewModule:observed.length, observedByNewModule:observed.length,
oldApiConsoleLogs:oldApiLogs.length,
observedByOldModule:state.fixtureLogs.filter( observedByOldModule:state.fixtureLogs.filter(
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)}) entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
).length ).length
@@ -335,13 +453,15 @@ async function runCase(options: {
if (session) { if (session) {
try { try {
await evaluate( await evaluate(
options.cliBinary,
session, session,
` `
const state=app.__livesyncLogLifecycleState; const state=app.__livesyncLogLifecycleState;
state?.restore?.(); try {
delete app.__livesyncLogLifecycleState; return JSON.stringify({cleaned:true});
return JSON.stringify({cleaned:true}); } finally {
state?.restore?.();
delete app.__livesyncLogLifecycleState;
}
` `
); );
} catch {} } 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 #### 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 #### WebApp