mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-02 22:38:18 +00:00
Make remote and log resource ownership explicit
This commit is contained in:
1 parent
db95f420b9
commit
28bc6e87a1
17 files changed
+974
-58
No files matched your search
@@ -277,6 +277,8 @@ The scenario uses only fixed in-memory inputs. It does not read or write the Vau
|
||||
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||
- This causes pretty significant performance overhead.
|
||||
|
||||
`ModuleLog` owns an `OwnedResource<LogFunction>` for its `API.addLog.setHandler` registration and keeps service log input in a per-instance buffer. The host wraps the complete asynchronous `control.onUnload()` in `withOwnedResource`. On successful shutdown, the handler and its global and per-instance observers remain registered through the final database close and final unload log. If `control.onUnload()` rejects, `withOwnedResource` releases them when the operation settles. Do not release it from an `onBeforeUnload` or module `onUnload` handler; both run before the host's complete unload finishes. The import-time global `Logger` bridge and display and report histories remain shared, while service log calls stay with their owning instance.
|
||||
|
||||
#### Diagnostic and notice ownership
|
||||
|
||||
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
||||
"test:e2e:obsidian:log-lifecycle": "tsx test/e2e-obsidian/scripts/log-lifecycle.ts",
|
||||
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
|
||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
+5
-2
@@ -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) => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,9 +68,8 @@ 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) => {
|
||||
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 })
|
||||
@@ -67,10 +77,13 @@ const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
||||
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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
type FixtureLog = {
|
||||
message: string;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type ShutdownObservation = {
|
||||
closeCalls: number;
|
||||
disposeCalls: number;
|
||||
disposeCompleted: boolean;
|
||||
fixtureLogs: FixtureLog[];
|
||||
events: string[];
|
||||
};
|
||||
|
||||
async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> {
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const require=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
const waitFor=async(predicate,message)=>{
|
||||
const until=Date.now()+15000;
|
||||
while(!predicate()) {
|
||||
if(Date.now()>until)throw new Error(message);
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
}
|
||||
};
|
||||
${body}
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginData(dbName: string): Record<string, unknown> {
|
||||
return createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName },
|
||||
{
|
||||
syncInternalFiles: true,
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
deviceAndVaultName: "log-lifecycle-device",
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFilesInterval: 0,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
showVerboseLog: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
vault: TemporaryVault
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: createPluginData(`log-lifecycle-${vault.name}`),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function createShutdownScript(options: {
|
||||
duringMarker: string;
|
||||
failureMarker: string;
|
||||
reloadMarker: string;
|
||||
injectFailure: boolean;
|
||||
urgentLevel: number;
|
||||
}): string {
|
||||
const fixtures = JSON.stringify(options);
|
||||
return `
|
||||
const fixtures=${fixtures};
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core,"The LiveSync plug-in did not expose its core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The loaded core did not contain ModuleLog");
|
||||
const api=core.services.API;
|
||||
const database=core.services.database.localDatabaseDirect;
|
||||
require(database,"The loaded core did not expose its local database");
|
||||
|
||||
let releaseCloseGate;
|
||||
const closeGate=new Promise(resolve=>{releaseCloseGate=resolve;});
|
||||
const state={
|
||||
core,
|
||||
api,
|
||||
module,
|
||||
fixtureLogs:[],
|
||||
events:[],
|
||||
closeStarted:false,
|
||||
closeCalls:0,
|
||||
disposeCalls:0,
|
||||
disposeCompleted:false,
|
||||
closeGateReleased:false,
|
||||
disableError:"",
|
||||
};
|
||||
app.__livesyncLogLifecycleState=state;
|
||||
|
||||
const originalAddLog=module.__addLog;
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && [
|
||||
fixtures.duringMarker,
|
||||
fixtures.failureMarker,
|
||||
fixtures.reloadMarker
|
||||
].some(marker=>marker && message.includes(marker))) {
|
||||
state.fixtureLogs.push({message,level,key});
|
||||
const marker=[fixtures.duringMarker,fixtures.failureMarker,fixtures.reloadMarker]
|
||||
.find(candidate=>candidate && message.includes(candidate));
|
||||
state.events.push("log:"+marker);
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
|
||||
const resource=module.logResource;
|
||||
const originalDispose=resource.dispose;
|
||||
resource.dispose=async function() {
|
||||
state.disposeCalls++;
|
||||
state.events.push("dispose-start");
|
||||
try {
|
||||
return await originalDispose.apply(this,arguments);
|
||||
} finally {
|
||||
state.disposeCompleted=true;
|
||||
state.events.push("dispose-complete");
|
||||
}
|
||||
};
|
||||
|
||||
const originalClose=database.close;
|
||||
database.close=async function() {
|
||||
state.closeCalls++;
|
||||
if(state.closeCalls===1) {
|
||||
state.closeStarted=true;
|
||||
state.events.push("close-start");
|
||||
await closeGate;
|
||||
}
|
||||
const result=await originalClose.apply(this,arguments);
|
||||
if(state.closeCalls===1) {
|
||||
state.events.push("close-finished");
|
||||
if(fixtures.injectFailure) {
|
||||
state.events.push("close-failure-injected");
|
||||
throw new Error(fixtures.failureMarker);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
state.restore=()=>{
|
||||
module.__addLog=originalAddLog;
|
||||
resource.dispose=originalDispose;
|
||||
database.close=originalClose;
|
||||
};
|
||||
|
||||
const run=async()=>{
|
||||
state.disablePromise=app.plugins.disablePlugin("obsidian-livesync");
|
||||
state.disablePromise.catch(error=>{state.disableError=String(error);});
|
||||
await waitFor(()=>state.closeStarted,"The real local database close did not start");
|
||||
require(state.disposeCalls===0 && !state.disposeCompleted,
|
||||
"The log resource was released before database shutdown completed");
|
||||
api.addLog(fixtures.duringMarker,undefined,"e2e-log-lifecycle-during-close");
|
||||
await waitFor(
|
||||
()=>state.fixtureLogs.some(entry=>entry.message.includes(fixtures.duringMarker)),
|
||||
"ModuleLog did not receive the during-close log"
|
||||
);
|
||||
require(state.disposeCalls===0,"The log resource was released while database close was gated");
|
||||
releaseCloseGate();
|
||||
state.closeGateReleased=true;
|
||||
await waitFor(()=>state.disposeCompleted,"The log resource was not released after database close");
|
||||
|
||||
require(state.closeCalls===1,"The local database close was not observed exactly once");
|
||||
require(state.disposeCalls===1,"The log resource was not disposed exactly once");
|
||||
require(state.events.indexOf("close-finished")>=0 &&
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("dispose-start"),
|
||||
"The log resource was disposed before the local database close completed");
|
||||
|
||||
const failedLog=fixtures.failureMarker
|
||||
? state.fixtureLogs.find(entry=>entry.message.includes(fixtures.failureMarker))
|
||||
: undefined;
|
||||
if(fixtures.injectFailure) {
|
||||
require(failedLog,"The injected post-close failure was not logged");
|
||||
require(failedLog.key==="shutdown","The shutdown failure log used the wrong key");
|
||||
require(failedLog.level===fixtures.urgentLevel,"The shutdown failure log was not urgent");
|
||||
require(
|
||||
state.events.indexOf("close-finished")<state.events.indexOf("log:"+fixtures.failureMarker) &&
|
||||
state.events.indexOf("log:"+fixtures.failureMarker)<state.events.indexOf("dispose-start"),
|
||||
"The shutdown failure was not logged between database close and log resource disposal"
|
||||
);
|
||||
} else {
|
||||
require(!failedLog,"The normal shutdown unexpectedly reported the injected failure");
|
||||
}
|
||||
|
||||
let detached=false;
|
||||
try {
|
||||
api.addLog("e2e-log-lifecycle-after-disposal");
|
||||
} catch(error) {
|
||||
detached=String(error).includes("not assigned");
|
||||
}
|
||||
require(detached,"The old API log handler remained assigned after disposal");
|
||||
await state.disablePromise;
|
||||
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
|
||||
return JSON.stringify({
|
||||
closeCalls:state.closeCalls,
|
||||
disposeCalls:state.disposeCalls,
|
||||
disposeCompleted:state.disposeCompleted,
|
||||
fixtureLogs:state.fixtureLogs,
|
||||
events:state.events
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
releaseCloseGate();
|
||||
if(state.closeStarted && !state.disposeCompleted) {
|
||||
try {
|
||||
await waitFor(()=>state.disposeCompleted,"Cleanup timed out waiting for log resource disposal");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function runCase(options: {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
caseName: string;
|
||||
injectFailure: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const vault = await createTemporaryVault(`obsidian-log-lifecycle-${options.caseName}-`);
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const markers = {
|
||||
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
|
||||
failureMarker: options.injectFailure
|
||||
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
|
||||
: "",
|
||||
reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`,
|
||||
injectFailure: options.injectFailure,
|
||||
urgentLevel: LOG_LEVEL_URGENT,
|
||||
};
|
||||
|
||||
try {
|
||||
session = await startSession(options.binary, options.cliBinary, vault);
|
||||
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
|
||||
const shutdown = await evaluate<ShutdownObservation>(options.cliBinary, session, createShutdownScript(markers));
|
||||
if (options.injectFailure) {
|
||||
return { caseName: options.caseName, shutdown };
|
||||
}
|
||||
|
||||
await evaluate(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});`
|
||||
);
|
||||
await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv);
|
||||
const reload = await evaluate<Record<string, unknown>>(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`
|
||||
const state=app.__livesyncLogLifecycleState;
|
||||
require(state,"The old core observation was not retained across plug-in reload");
|
||||
const plugin=app.plugins.plugins["obsidian-livesync"];
|
||||
const core=plugin?.core;
|
||||
require(core && core!==state.core,"Enabling the plug-in did not create a new core");
|
||||
const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog");
|
||||
require(module,"The reloaded core did not contain ModuleLog");
|
||||
const originalAddLog=module.__addLog;
|
||||
const observed=[];
|
||||
module.__addLog=function(message,level,key) {
|
||||
if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) {
|
||||
observed.push({message,level,key});
|
||||
}
|
||||
return originalAddLog.apply(this,arguments);
|
||||
};
|
||||
try {
|
||||
core.services.API.addLog(${JSON.stringify(markers.reloadMarker)},undefined,"e2e-log-lifecycle-reload");
|
||||
await waitFor(()=>observed.length>0,"The reloaded ModuleLog did not receive its API log");
|
||||
require(observed.length===1,"The reloaded ModuleLog received the fixture log more than once");
|
||||
require(
|
||||
!state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})),
|
||||
"The old ModuleLog observer received the reloaded core's fixture log"
|
||||
);
|
||||
let oldHandlerDetached=false;
|
||||
try {
|
||||
state.api.addLog("e2e-log-lifecycle-reloaded-old-api");
|
||||
} catch(error) {
|
||||
oldHandlerDetached=String(error).includes("not assigned");
|
||||
}
|
||||
require(oldHandlerDetached,"The old API log handler was reassigned after reload");
|
||||
return JSON.stringify({
|
||||
coreChanged:core!==state.core,
|
||||
observedByNewModule:observed.length,
|
||||
observedByOldModule:state.fixtureLogs.filter(
|
||||
entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})
|
||||
).length
|
||||
});
|
||||
} finally {
|
||||
module.__addLog=originalAddLog;
|
||||
state.restore();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
}
|
||||
`
|
||||
);
|
||||
return { caseName: options.caseName, shutdown, reload };
|
||||
} finally {
|
||||
if (session) {
|
||||
try {
|
||||
await evaluate(
|
||||
options.cliBinary,
|
||||
session,
|
||||
`
|
||||
const state=app.__livesyncLogLifecycleState;
|
||||
state?.restore?.();
|
||||
delete app.__livesyncLogLifecycleState;
|
||||
return JSON.stringify({cleaned:true});
|
||||
`
|
||||
);
|
||||
} catch {}
|
||||
try {
|
||||
await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
} else {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
|
||||
const observations = [
|
||||
await runCase({ binary, cliBinary, caseName: "normal", injectFailure: false }),
|
||||
await runCase({ binary, cliBinary, caseName: "controlled-failure", injectFailure: true }),
|
||||
];
|
||||
console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -49,6 +49,7 @@ const focusedScenarios = new Set([
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"optional-file-save-completion",
|
||||
"log-lifecycle",
|
||||
"livesync-batch",
|
||||
"received-change-readiness",
|
||||
"internal-metadata-doctor",
|
||||
|
||||
@@ -12,6 +12,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Diagnostics
|
||||
|
||||
- Service log calls stay with the plug-in instance that registered them. Its log connection is released after the full shutdown, so stale instances cannot receive later service messages.
|
||||
|
||||
|
||||
## 1.0.42
|
||||
|
||||
27th October, 2026
|
||||
|
||||
Reference in new issue
Block a user