Make remote and log resource ownership explicit

This commit is contained in:
vorotamoroz committed 2026-10-29 14:56:06 +00:00
1 parent db95f420b9
commit 28bc6e87a1
17 files changed
+987 -71

No files matched your search

+4 -6
View File
@@ -1,3 +1,5 @@
import { withOwnedResource, type DisposableResource } from "octagonal-wheels/resource";
/**
* Run a finite operation with a flow-owned remote resource and release it
* after either success or failure.
@@ -6,13 +8,9 @@
* caller's ownership boundary explicit and prevents finite flows from leaking
* a provider-owned resource when their operation rejects.
*/
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
export function withOwnedRemoteResource<TResource extends DisposableResource & { dispose(): Promise<void> }, TResult>(
resource: TResource,
operation: (ownedResource: TResource) => Promise<TResult>
): Promise<TResult> {
try {
return await operation(resource);
} finally {
await resource.dispose();
}
return withOwnedResource(resource, operation);
}
@@ -26,3 +26,47 @@ describe("flow-owned remote resources", () => {
expect(dispose).toHaveBeenCalledOnce();
});
});
describe("owned remote resource completion", () => {
it("waits for cleanup and preserves its receiver", async () => {
let finish!: () => void;
let started!: () => void;
const disposing = new Promise<void>((resolve) => {
started = resolve;
});
const cleanup = new Promise<void>((resolve) => {
finish = resolve;
});
const resource = {
dispose: vi.fn(function (this: unknown) {
expect(this).toBe(resource);
started();
return cleanup;
}),
};
let settled = false;
const result = withOwnedRemoteResource(resource, async () => "done");
void result.then(() => {
settled = true;
});
await disposing;
expect(settled).toBe(false);
finish();
await expect(result).resolves.toBe("done");
expect(resource.dispose).toHaveBeenCalledOnce();
});
it("keeps finally precedence when both the operation and cleanup fail", async () => {
const operationError = new Error("operation");
const disposalError = new Error("cleanup");
const dispose = vi.fn(async () => {
throw disposalError;
});
await expect(
withOwnedRemoteResource({ dispose }, async () => {
throw operationError;
})
).rejects.toBe(disposalError);
expect(dispose).toHaveBeenCalledOnce();
});
});
+6 -19
View File
@@ -73,7 +73,7 @@ async function runOneShotWithOutcome(
// Manual and unattended wrappers share the provider transfer operation, but
// keep interaction authority and result presentation explicit at this boundary.
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
const userInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
return await runOneShotWithOutcome(
instance,
setting,
@@ -81,20 +81,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
);
};
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
return await runOneShotWithOutcome(instance, setting, false);
};
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
return await runOneShotWithOutcome(
instance,
setting,
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
const unattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
return await runOneShotWithOutcome(instance, setting, false);
};
@@ -136,8 +123,8 @@ export function createCentralReplicatorProviderDefinitions(
),
},
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
unattendedOneShot: supportedCapability(unattendedOneShot),
continuous: supportedOpenReplicationContinuous(),
stopActiveTransfer: supportedStopActiveTransfer(),
},
@@ -166,8 +153,8 @@ export function createCentralReplicatorProviderDefinitions(
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
},
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
unattendedOneShot: supportedCapability(unattendedOneShot),
continuous: CAPABILITY_NOT_APPLICABLE,
stopActiveTransfer: supportedStopActiveTransfer(),
},
@@ -209,6 +209,37 @@ describe("central Replicator provider definitions", () => {
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
});
it.each([REMOTE_COUCHDB, REMOTE_MINIO] as const)(
"keeps unattended %s work quiet and rejects interaction before opening replication",
async (remoteType) => {
const definition = createCentralReplicatorProviderDefinitions({} as never).get(remoteType)!;
if (definition.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
throw new Error("Unattended OneShot is unavailable");
}
const setting = Object.assign(createNewVaultSettings(), { remoteType });
const instance = {
initializeDatabaseForReplication: vi.fn(async () => true),
openReplication: vi.fn(async () => true),
terminateSync: vi.fn(),
closeReplication: vi.fn(),
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
};
const blocked = await definition.unattendedOneShot.run(instance, setting, {
trigger: "resume",
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(blocked).toEqual({ status: "blocked", reason: "interaction-required" });
expect(instance.openOneShotReplicationWithOutcome).not.toHaveBeenCalled();
await expect(
definition.unattendedOneShot.run(instance, setting, {
trigger: "resume",
interaction: NO_INTERACTION,
})
).resolves.toBe(REPLICATION_COMPLETED);
expect(instance.openOneShotReplicationWithOutcome).toHaveBeenCalledExactlyOnceWith(setting, false);
}
);
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const couchDB = definitions.get(REMOTE_COUCHDB)!;
+3 -7
View File
@@ -1,3 +1,4 @@
import { createOwnedResource } from "octagonal-wheels/resource";
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
@@ -13,13 +14,8 @@ export interface ResourceReplicator {
/** Create one idempotent asynchronous disposer for a private Replicator. */
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
let disposal: Promise<void> | undefined;
return () => {
if (disposal === undefined) {
disposal = Promise.resolve().then(() => replicator.closeReplication());
}
return disposal;
};
const resource = createOwnedResource(replicator, (owned) => Promise.resolve().then(() => owned.closeReplication()));
return () => Promise.resolve(resource.dispose());
}
/** Fence a finite resource from later edits to its source settings object. */
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createReplicatorDisposer } from "./shared";
describe("Replicator disposal", () => {
it("defers closing, preserves the receiver, and retains one completion promise", async () => {
let finish!: () => void;
const closed = new Promise<void>((resolve) => {
finish = resolve;
});
const replicator = {
closeReplication: vi.fn(function (this: unknown) {
expect(this).toBe(replicator);
return closed;
}),
};
const dispose = createReplicatorDisposer(replicator);
const pending = dispose();
expect(replicator.closeReplication).not.toHaveBeenCalled();
expect(dispose()).toBe(pending);
await Promise.resolve();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
finish();
await pending;
expect(dispose()).toBe(pending);
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("retains synchronous closing failures as one rejected promise", async () => {
const error = new Error("close");
const replicator = {
closeReplication: vi.fn(() => {
throw error;
}),
};
const dispose = createReplicatorDisposer(replicator);
const pending = dispose();
await expect(pending).rejects.toBe(error);
expect(dispose()).toBe(pending);
await expect(dispose()).rejects.toBe(error);
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOwnedResource } from "octagonal-wheels/resource";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
const unusedStartupDependencies: readonly [string, readonly string[]][] = [
["@/serviceFeatures/useP2PSettingsPreparation", ["useP2PSettingsPreparation"]],
["@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", ["setGetLanguage"]],
["./features/LiveSyncCommands.ts", ["LiveSyncCommands"]],
["./features/HiddenFileSync/CmdHiddenFileSync.ts", ["HiddenFileSync"]],
["./features/ConfigSync/CmdConfigSync.ts", ["ConfigSync"]],
["./modules/features/ModuleLog.ts", ["ModuleLog"]],
["./modules/essentialObsidian/ModuleObsidianEvents.ts", ["ModuleObsidianEvents"]],
["./modules/features/ModuleObsidianSettingTab.ts", ["ModuleObsidianSettingDialogue"]],
["./modules/features/ModuleObsidianDocumentHistory.ts", ["ModuleObsidianDocumentHistory"]],
["./modules/features/ModuleGlobalHistory.ts", ["ModuleObsidianGlobalHistory"]],
["./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts", ["LocalDatabaseMaintenance"]],
["./modules/services/ObsidianServiceHub.ts", ["ObsidianServiceHub"]],
["@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", ["ServiceRebuilder"]],
["@/serviceModules/DatabaseFileAccess.ts", ["ServiceDatabaseFileAccess"]],
["@/serviceModules/ServiceFileAccessImpl.ts", ["ServiceFileAccessObsidian"]],
["@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", ["StorageAccessManager"]],
["./serviceModules/FileHandler.ts", ["ServiceFileHandler"]],
["./serviceModules/FileAccessObsidian.ts", ["FileAccessObsidian"]],
["./managers/StorageEventManagerObsidian.ts", ["StorageEventManagerObsidian"]],
["@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper", ["setNoticeClass"]],
["./LiveSyncBaseCore.ts", ["LiveSyncBaseCore"]],
["./modules/features/ModuleObsidianSettingAsMarkdown.ts", ["ModuleObsidianSettingsAsMarkdown"]],
["./modules/features/SetupManager.ts", ["SetupManager"]],
["./serviceFeatures/onLayoutReady/enablei18n.ts", ["enableI18nFeature"]],
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", ["useOfflineScanner"]],
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", ["useRemoteConfiguration"]],
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", ["useCheckRemoteSize"]],
["./serviceFeatures/redFlag.ts", ["useRedFlagFeatures"]],
["./serviceFeatures/setupObsidian/setupProtocol.ts", ["useSetupProtocolFeature"]],
["@/serviceFeatures/setupObsidian/qrCode", ["useSetupQRCodeFeature"]],
["@/serviceFeatures/setupObsidian/setupUri", ["useSetupURIFeature"]],
[
"./serviceFeatures/setupObsidian/setupManagerHandlers.ts",
["showOnboardingInvitation", "useSetupManagerHandlersFeature"],
],
["@vrtmrz/livesync-commonlib/p2p", ["useP2PReplicatorCommands", "useP2PReplicatorFeature"]],
["./serviceFeatures/useP2PReplicatorUI.ts", ["useP2PReplicatorUI"]],
["./serviceFeatures/useReviewHarness.ts", ["useReviewHarness"]],
["./features/P2PSync/P2PReplicator/P2PReplicationUI.ts", ["createOpenReplicationUI", "createOpenRebuildUI"]],
["./serviceFeatures/compatibilityReview.ts", ["useCompatibilityReview"]],
["./serviceFeatures/compatibilityReviewObsidian.ts", ["createObsidianCompatibilityReviewUi"]],
["./serviceModules/FileReflectionProvenance.ts", ["createFileReflectionProvenance"]],
["./serviceFeatures/interactiveConflictResolution", ["useInteractiveConflictResolutionFeature"]],
["./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", ["ConflictResolveModal"]],
["./serviceFeatures/obsidianReplicationRibbon.ts", ["useObsidianReplicationRibbonFeature"]],
["./serviceFeatures/startupLifecycle", ["useStartupLifecycleFeature"]],
["./serviceFeatures/useJoinedDevices", ["useJoinedDevicesFeature"]],
["./serviceFeatures/useDatabaseDiagnostics", ["useDatabaseDiagnosticsFeature"]],
];
beforeEach(() => {
vi.resetModules();
vi.doMock("./deps", () => ({ Plugin: class {}, Notice: class {}, getLanguage: () => "en" }));
for (const [specifier, names] of unusedStartupDependencies) {
vi.doMock(specifier, () => Object.fromEntries(names.map((name) => [name, vi.fn()])));
}
});
describe("Obsidian plug-in unload logging", () => {
it.each(["complete", "fail"] as const)(
"retains its log connection through shutdown and retires it afterwards: %s",
async (outcome) => {
const { default: LiveSyncPlugin } = await import("./main.ts");
const events: string[] = [];
const sink = vi.fn((message: unknown) => events.push(String(message)));
const addLog = bindableFunction<(message: unknown, level?: number, key?: string) => void>("unload log");
const unregister = addLog.setHandler(sink);
const dispose = vi.fn(() => {
unregister();
events.push("disposed");
});
const logResource = createOwnedResource(sink, dispose);
let finish!: () => void;
let fail!: (reason: Error) => void;
const shutdown = new Promise<void>((resolve, reject) => {
finish = resolve;
fail = reject;
});
const onUnload = vi.fn(() => shutdown);
const core = {
getModule: vi.fn(() => ({ logResource })),
services: { API: { addLog }, control: { onUnload } },
};
expect(LiveSyncPlugin.prototype.onunload.call({ core } as never)).toBeUndefined();
expect(onUnload).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
addLog("during shutdown");
const failure = new Error("database close failed");
if (outcome === "fail") {
fail(failure);
} else {
finish();
}
await vi.waitFor(() => expect(dispose).toHaveBeenCalledTimes(1));
if (outcome === "fail") {
const message = "Failed to unload Self-hosted LiveSync: " + String(failure);
expect(sink).toHaveBeenCalledWith(message, LOG_LEVEL_URGENT, "shutdown");
expect(events).toEqual(["during shutdown", message, "disposed"]);
} else {
expect(events).toEqual(["during shutdown", "disposed"]);
}
expect(() => addLog("after shutdown")).toThrow("not assigned");
}
);
});
+10 -7
View File
@@ -9,6 +9,7 @@ import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
import { ModuleLog } from "./modules/features/ModuleLog.ts";
import { withOwnedResource } from "octagonal-wheels/resource";
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
@@ -234,12 +235,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
void this._startUp();
}
override onunload(): void {
void this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
});
void withOwnedResource(this.core.getModule(ModuleLog).logResource, () =>
this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
})
);
}
}
+55 -22
View File
@@ -1,4 +1,10 @@
import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import {
computed,
reactive,
reactiveSource,
type ReactiveSource,
type ReactiveValue,
} from "octagonal-wheels/dataobject/reactive";
import {
LOG_LEVEL_DEBUG,
LOG_LEVEL_INFO,
@@ -7,6 +13,7 @@ import {
type LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { scheduleTask } from "octagonal-wheels/concurrency/task";
import { createOwnedResource, type OwnedResource } from "octagonal-wheels/resource";
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
chunkFetchCounts,
@@ -46,7 +53,11 @@ import {
isValidFilenameInDarwin,
isValidFilenameInWidows,
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
MARK_LOG_NETWORK_ERROR,
MARK_LOG_SEPARATOR,
type LogFunction,
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { generateReport } from "@/common/reportTool.ts";
@@ -57,20 +68,22 @@ import {
// This module cannot be a core module because it depends on the Obsidian UI.
// DI the log again.
const recentLogEntries = reactiveSource<LogEntry[]>([]);
const globalLogFunction = (message: unknown, level?: number, key?: string) => {
const messageX =
message instanceof Error
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
: typeof message === "string"
? message
: JSON.stringify(message);
const entry = { message: messageX, level, key } as LogEntry;
recentLogEntries.value = [...recentLogEntries.value, entry];
};
function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
return (message: unknown, level?: number, key?: string) => {
const messageX =
message instanceof Error
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
: typeof message === "string"
? message
: JSON.stringify(message);
const entry = { message: messageX, level, key } as LogEntry;
entries.value = [...entries.value, entry];
};
}
setGlobalLogFunction(globalLogFunction);
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
const logForDump = [] as string[];
@@ -113,6 +126,16 @@ function redactLog(log: string) {
const showDebugLog = false;
export const MARK_DONE = "\u{2009}\u{2009}";
export class ModuleLog extends AbstractObsidianModule {
/** The host releases this connection after the complete asynchronous unload. */
logResource!: OwnedResource<LogFunction>;
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
private readonly displayLogEntries = () => {
if (this.recentLogEntries.value.length === 0) return;
const entries = [...this.recentLogEntries.value];
this.recentLogEntries.value = [];
entries.forEach((entry) => this.__addLog(entry.message, entry.level, entry.key));
};
statusBar?: HTMLElement;
statusDiv?: HTMLElement;
@@ -460,12 +483,7 @@ ${stringifyYaml(info)}
return Promise.resolve(true);
}
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
recentLogEntries.onChanged((entries) => {
if (entries.value.length === 0) return;
const newEntries = [...entries.value];
recentLogEntries.value = [];
newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key));
});
this.recentLogEntries.onChanged(this.displayLogEntries);
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
void this.setFileStatus();
});
@@ -597,7 +615,22 @@ ${stringifyYaml(info)}
}
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.API.addLog.setHandler(globalLogFunction);
const log = createBufferedLog(this.recentLogEntries);
const unregisterLog = services.API.addLog.setHandler(log);
const collectGlobalLogs = () => {
const entries = recentGlobalLogEntries.value;
if (entries.length === 0) return;
recentGlobalLogEntries.value = [];
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
};
this.logResource = createOwnedResource(log, () => {
unregisterLog();
recentGlobalLogEntries.offChanged(collectGlobalLogs);
this.recentLogEntries.offChanged(this.displayLogEntries);
this.recentLogEntries.value = [];
});
recentGlobalLogEntries.onChanged(collectGlobalLogs);
collectGlobalLogs();
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
+228
View File
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
debounce: (callback: () => void) => callback,
normalizePath: (path: string) => path,
Notice: class {},
stringifyYaml: vi.fn(),
}));
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@/common/events.ts", () => ({
EVENT_FILE_RENAMED: "renamed",
EVENT_LAYOUT_READY: "ready",
EVENT_LEAF_ACTIVE_CHANGED: "active",
EVENT_ON_UNRESOLVED_ERROR: "error",
eventHub: { onEvent: vi.fn() },
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
P2PLogCollector: class {},
}));
async function createHarness() {
const { ModuleLog } = await import("./ModuleLog.ts");
const { bindableFunction, allFunction } =
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
const services = {
API: {
addLog: bindableFunction<(message: unknown, level?: number, key?: string) => void>("log"),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
context: { events: {}, notices: { hide: vi.fn() } },
appLifecycle: {
onInitialise: allFunction<() => Promise<boolean>>(),
onSettingLoaded: allFunction<() => Promise<boolean>>(),
onLoaded: allFunction<() => Promise<boolean>>(),
onBeforeUnload: allFunction<() => Promise<boolean>>(),
},
};
const core = { services, settings: {} };
const module = new ModuleLog({ app: {} } as never, core as never);
const sink = vi.spyOn(module, "__addLog").mockImplementation(() => undefined);
vi.spyOn(module, "observeForLogs").mockImplementation(() => undefined);
vi.spyOn(module, "adjustStatusDivPosition").mockImplementation(() => undefined);
module.onBindFunction(core as never, services as never);
const { Logger } = await import("octagonal-wheels/common/logger");
return { module, services, sink, Logger };
}
describe("ModuleLog service connection", () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("document", { querySelectorAll: () => [] });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("buffers startup logs and preserves global and service call order", async () => {
const { services, sink, Logger } = await createHarness();
Logger("before-global");
services.API.addLog("before-service", 32, "startup");
Logger("between-global");
expect(sink).not.toHaveBeenCalled();
await services.appLifecycle.onSettingLoaded();
const messages = sink.mock.calls.map(([message]) => message);
expect(messages.filter((message) => typeof message === "string" && /^(before|between)-/.test(message))).toEqual(
["before-global", "before-service", "between-global"]
);
expect(sink).toHaveBeenCalledWith("before-service", 32, "startup");
});
it("delivers service logs only to their own module", async () => {
const first = await createHarness();
const second = await createHarness();
await first.services.appLifecycle.onSettingLoaded();
await second.services.appLifecycle.onSettingLoaded();
first.sink.mockClear();
second.sink.mockClear();
first.services.API.addLog("first", 32, "first-key");
second.services.API.addLog("second", 64, "second-key");
expect(first.sink.mock.calls).toEqual([["first", 32, "first-key"]]);
expect(second.sink.mock.calls).toEqual([["second", 64, "second-key"]]);
});
it("preserves message conversion, levels, keys, and error causes", async () => {
const { services, sink } = await createHarness();
await services.appLifecycle.onSettingLoaded();
sink.mockClear();
const error = new Error("failed");
services.API.addLog(error, 128, "failure");
services.API.addLog({ result: "value" }, 32, "object");
services.API.addLog("plain");
const converted = sink.mock.calls[0][0] as Error & { cause: unknown };
expect(converted.message).toBe("[Error Logged]: failed");
expect(converted.cause).toBe(error);
expect(sink.mock.calls[0].slice(1)).toEqual([128, "failure"]);
expect(sink.mock.calls.slice(1)).toEqual([
['{"result":"value"}', 32, "object"],
["plain", undefined, undefined],
]);
});
it("detaches both service registration and legacy subscription on disposal", async () => {
const first = await createHarness();
const second = await createHarness();
await first.services.appLifecycle.onSettingLoaded();
await second.services.appLifecycle.onSettingLoaded();
first.sink.mockClear();
second.sink.mockClear();
await first.module.logResource.dispose();
await first.module.logResource.dispose();
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
second.services.API.addLog("active", 32, "service");
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();
});
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
const { services, module, sink, Logger } = await createHarness();
await services.appLifecycle.onSettingLoaded();
const replacement = vi.fn();
services.API.addLog.setHandler(replacement, true);
sink.mockClear();
await module.logResource.dispose();
await module.logResource.dispose();
services.API.addLog("replacement", 32, "key");
Logger("detached-legacy");
expect(replacement).toHaveBeenCalledExactlyOnceWith("replacement", 32, "key");
expect(sink).not.toHaveBeenCalled();
});
it("releases a connection which never reached the settings-loaded phase", async () => {
const { services, module, sink, Logger } = await createHarness();
services.API.addLog("pending");
await module.logResource.dispose();
Logger("after-disposal");
expect(sink).not.toHaveBeenCalled();
expect(() => services.API.addLog("retired")).toThrow("not assigned");
const replacement = await createHarness();
await replacement.services.appLifecycle.onSettingLoaded();
const messages = replacement.sink.mock.calls.map(([message]) => message);
expect(messages).toContain("after-disposal");
expect(messages).not.toContain("pending");
await replacement.module.logResource.dispose();
});
it.each(["complete", "fail"] as const)(
"keeps logging through asynchronous database shutdown and releases after it: %s",
async (outcome) => {
const { services, module, sink, Logger } = await createHarness();
await services.appLifecycle.onSettingLoaded();
sink.mockClear();
const { ControlService } = await import("@vrtmrz/livesync-commonlib/compat/services/base/ControlService");
const { ServiceContext } = await import("@vrtmrz/livesync-commonlib/context");
const { withOwnedResource } = await import("octagonal-wheels/resource");
let finish!: () => void;
let fail!: (reason: Error) => void;
let started!: () => void;
const closing = new Promise<void>((resolve) => {
started = resolve;
});
const closed = new Promise<void>((resolve, reject) => {
finish = resolve;
fail = reject;
});
const control = new ControlService(new ServiceContext({ translate: (key) => key }), {
appLifecycleService: {
...services.appLifecycle,
onAppUnload: vi.fn(async () => []),
onUnload: vi.fn(async () => {
services.API.addLog("lifecycle-unload");
return true;
}),
},
APIService: services.API,
databaseService: {
localDatabaseDirect: {
onunload: vi.fn(() => services.API.addLog("database-unload")),
close: vi.fn(() => {
started();
return closed;
}),
},
},
} as never);
const unloading = withOwnedResource(module.logResource, () => control.onUnload());
await closing;
services.API.addLog("during-close");
expect(sink.mock.calls.map(([message]) => message)).toEqual([
"lifecycle-unload",
"database-unload",
"during-close",
]);
if (outcome === "fail") {
const failure = new Error("database close failed");
const rejected = expect(unloading).rejects.toBe(failure);
fail(failure);
await rejected;
} else {
finish();
await unloading;
expect(sink.mock.calls[sink.mock.calls.length - 1][0]).toContain(
"moduleLiveSyncMain.logUnloadingPlugin"
);
}
expect(() => services.API.addLog("retired")).toThrow("not assigned");
sink.mockClear();
Logger("after-unload");
expect(sink).not.toHaveBeenCalled();
}
);
});
+7 -10
View File
@@ -9,6 +9,7 @@ import {
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import {
getCouchDBReplicatorConfigurationIdentity,
getObjectStorageReplicatorConfigurationIdentity,
@@ -119,9 +120,9 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
settings
);
if (!resource) throw new Error("Joined devices are unavailable");
try {
return withOwnedRemoteResource(resource, async (ownedResource) => {
assertCurrent();
const snapshot = await resource.read();
const snapshot = await ownedResource.read();
assertCurrent();
notify(snapshot, id, identity!);
return joinedDevicesView(snapshot, {
@@ -130,9 +131,7 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
pluginVersion: services.API.getPluginVersion(),
appVersion: services.API.getAppVersion(),
});
} finally {
await resource.dispose();
}
});
},
forget: async (id) => {
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
@@ -141,14 +140,12 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
settings
);
if (!resource) throw new Error("Joined devices are unavailable");
try {
return withOwnedRemoteResource(resource, async (ownedResource) => {
assertCurrent();
const result = await resource.forget(id);
const result = await ownedResource.forget(id);
assertCurrent();
return result;
} finally {
await resource.dispose();
}
});
},
cancel: () => controller.abort(),
};
@@ -110,6 +110,67 @@ describe("joined devices service feature", () => {
expect(await pending).toEqual({ status: "handled", value: "closed" });
await expect(options.refresh()).rejects.toThrow();
});
it("propagates a failed refresh after releasing its resource", async () => {
const data = fixture();
const failure = new Error("joined devices read failed");
data.read.mockRejectedValueOnce(failure);
const { options, pending } = await open(data);
await expect(options.refresh()).rejects.toBe(failure);
expect(data.dispose).toHaveBeenCalledOnce();
data.close();
await pending;
});
it.each(["refresh", "forget"] as const)(
"waits for resource disposal after %s",
async (operationName) => {
const data = fixture();
let finishDisposal!: () => void;
data.dispose.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishDisposal = resolve;
})
);
const { options, pending } = await open(data);
const operation = operationName === "refresh" ? options.refresh() : options.forget("other");
let settled = false;
void operation.then(
() => {
settled = true;
},
() => {
settled = true;
}
);
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
expect(settled).toBe(false);
finishDisposal();
await expect(operation).resolves.toBeDefined();
expect(settled).toBe(true);
data.close();
await pending;
}
);
it.each(["refresh", "forget"] as const)(
"uses disposal failure precedence when %s also fails",
async (operationName) => {
const data = fixture();
const operationFailure = new Error("joined devices operation failed");
const disposalFailure = new Error("joined devices disposal failed");
if (operationName === "refresh") data.read.mockRejectedValueOnce(operationFailure);
else data.forget.mockRejectedValueOnce(operationFailure);
data.dispose.mockRejectedValueOnce(disposalFailure);
const { options, pending } = await open(data);
const operation = operationName === "refresh" ? options.refresh() : options.forget("other");
await expect(operation).rejects.toBe(disposalFailure);
expect(data.dispose).toHaveBeenCalledOnce();
data.close();
await pending;
}
);
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
const data = fixture();
const { options, pending } = await open(data);