mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48: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)
|
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||||
- This causes pretty significant performance overhead.
|
- 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
|
#### 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.
|
- 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-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: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: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: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: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",
|
"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
|
* Run a finite operation with a flow-owned remote resource and release it
|
||||||
* after either success or failure.
|
* after either success or failure.
|
||||||
@@ -6,13 +8,9 @@
|
|||||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||||
* a provider-owned resource when their operation rejects.
|
* 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,
|
resource: TResource,
|
||||||
operation: (ownedResource: TResource) => Promise<TResult>
|
operation: (ownedResource: TResource) => Promise<TResult>
|
||||||
): Promise<TResult> {
|
): Promise<TResult> {
|
||||||
try {
|
return withOwnedResource(resource, operation);
|
||||||
return await operation(resource);
|
|
||||||
} finally {
|
|
||||||
await resource.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -26,3 +26,47 @@ describe("flow-owned remote resources", () => {
|
|||||||
expect(dispose).toHaveBeenCalledOnce();
|
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
|
// Manual and unattended wrappers share the provider transfer operation, but
|
||||||
// keep interaction authority and result presentation explicit at this boundary.
|
// 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(
|
return await runOneShotWithOutcome(
|
||||||
instance,
|
instance,
|
||||||
setting,
|
setting,
|
||||||
@@ -81,20 +81,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const couchDBUnattendedOneShot: 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||||
return await runOneShotWithOutcome(instance, setting, false);
|
return await runOneShotWithOutcome(instance, setting, false);
|
||||||
};
|
};
|
||||||
@@ -136,8 +123,8 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||||
continuous: supportedOpenReplicationContinuous(),
|
continuous: supportedOpenReplicationContinuous(),
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
@@ -166,8 +153,8 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||||
},
|
},
|
||||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -209,6 +209,37 @@ describe("central Replicator provider definitions", () => {
|
|||||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
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 () => {
|
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
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";
|
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. */
|
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
||||||
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
||||||
let disposal: Promise<void> | undefined;
|
const resource = createOwnedResource(replicator, (owned) => Promise.resolve().then(() => owned.closeReplication()));
|
||||||
return () => {
|
return () => Promise.resolve(resource.dispose());
|
||||||
if (disposal === undefined) {
|
|
||||||
disposal = Promise.resolve().then(() => replicator.closeReplication());
|
|
||||||
}
|
|
||||||
return disposal;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fence a finite resource from later edits to its source settings object. */
|
/** 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 { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||||
|
|
||||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||||
|
import { withOwnedResource } from "octagonal-wheels/resource";
|
||||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||||
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
||||||
@@ -234,12 +235,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
void this._startUp();
|
void this._startUp();
|
||||||
}
|
}
|
||||||
override onunload(): void {
|
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(
|
this.core.services.API.addLog(
|
||||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||||
LOG_LEVEL_URGENT,
|
LOG_LEVEL_URGENT,
|
||||||
"shutdown"
|
"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 {
|
import {
|
||||||
LOG_LEVEL_DEBUG,
|
LOG_LEVEL_DEBUG,
|
||||||
LOG_LEVEL_INFO,
|
LOG_LEVEL_INFO,
|
||||||
@@ -7,6 +13,7 @@ import {
|
|||||||
type LOG_LEVEL,
|
type LOG_LEVEL,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
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 { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import {
|
import {
|
||||||
chunkFetchCounts,
|
chunkFetchCounts,
|
||||||
@@ -46,7 +53,11 @@ import {
|
|||||||
isValidFilenameInDarwin,
|
isValidFilenameInDarwin,
|
||||||
isValidFilenameInWidows,
|
isValidFilenameInWidows,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
} 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 { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { generateReport } from "@/common/reportTool.ts";
|
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.
|
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||||
|
|
||||||
// DI the log again.
|
function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||||
const recentLogEntries = reactiveSource<LogEntry[]>([]);
|
return (message: unknown, level?: number, key?: string) => {
|
||||||
const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
|
||||||
const messageX =
|
const messageX =
|
||||||
message instanceof Error
|
message instanceof Error
|
||||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||||
@@ -67,10 +77,13 @@ const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
|||||||
? message
|
? message
|
||||||
: JSON.stringify(message);
|
: JSON.stringify(message);
|
||||||
const entry = { message: messageX, level, key } as LogEntry;
|
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.
|
// 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.
|
// 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[];
|
const logForDump = [] as string[];
|
||||||
@@ -113,6 +126,16 @@ 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. */
|
||||||
|
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;
|
statusBar?: HTMLElement;
|
||||||
|
|
||||||
statusDiv?: HTMLElement;
|
statusDiv?: HTMLElement;
|
||||||
@@ -460,12 +483,7 @@ ${stringifyYaml(info)}
|
|||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||||
recentLogEntries.onChanged((entries) => {
|
this.recentLogEntries.onChanged(this.displayLogEntries);
|
||||||
if (entries.value.length === 0) return;
|
|
||||||
const newEntries = [...entries.value];
|
|
||||||
recentLogEntries.value = [];
|
|
||||||
newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key));
|
|
||||||
});
|
|
||||||
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
||||||
void this.setFileStatus();
|
void this.setFileStatus();
|
||||||
});
|
});
|
||||||
@@ -597,7 +615,22 @@ ${stringifyYaml(info)}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
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.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.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 { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||||
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
||||||
|
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||||
import {
|
import {
|
||||||
getCouchDBReplicatorConfigurationIdentity,
|
getCouchDBReplicatorConfigurationIdentity,
|
||||||
getObjectStorageReplicatorConfigurationIdentity,
|
getObjectStorageReplicatorConfigurationIdentity,
|
||||||
@@ -119,9 +120,9 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
|||||||
settings
|
settings
|
||||||
);
|
);
|
||||||
if (!resource) throw new Error("Joined devices are unavailable");
|
if (!resource) throw new Error("Joined devices are unavailable");
|
||||||
try {
|
return withOwnedRemoteResource(resource, async (ownedResource) => {
|
||||||
assertCurrent();
|
assertCurrent();
|
||||||
const snapshot = await resource.read();
|
const snapshot = await ownedResource.read();
|
||||||
assertCurrent();
|
assertCurrent();
|
||||||
notify(snapshot, id, identity!);
|
notify(snapshot, id, identity!);
|
||||||
return joinedDevicesView(snapshot, {
|
return joinedDevicesView(snapshot, {
|
||||||
@@ -130,9 +131,7 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
|||||||
pluginVersion: services.API.getPluginVersion(),
|
pluginVersion: services.API.getPluginVersion(),
|
||||||
appVersion: services.API.getAppVersion(),
|
appVersion: services.API.getAppVersion(),
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
await resource.dispose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
forget: async (id) => {
|
forget: async (id) => {
|
||||||
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
||||||
@@ -141,14 +140,12 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
|||||||
settings
|
settings
|
||||||
);
|
);
|
||||||
if (!resource) throw new Error("Joined devices are unavailable");
|
if (!resource) throw new Error("Joined devices are unavailable");
|
||||||
try {
|
return withOwnedRemoteResource(resource, async (ownedResource) => {
|
||||||
assertCurrent();
|
assertCurrent();
|
||||||
const result = await resource.forget(id);
|
const result = await ownedResource.forget(id);
|
||||||
assertCurrent();
|
assertCurrent();
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
});
|
||||||
await resource.dispose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cancel: () => controller.abort(),
|
cancel: () => controller.abort(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,6 +110,67 @@ describe("joined devices service feature", () => {
|
|||||||
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
||||||
await expect(options.refresh()).rejects.toThrow();
|
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 () => {
|
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
|
||||||
const data = fixture();
|
const data = fixture();
|
||||||
const { options, pending } = await open(data);
|
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-sync",
|
||||||
"customisation-catalogue-reload",
|
"customisation-catalogue-reload",
|
||||||
"optional-file-save-completion",
|
"optional-file-save-completion",
|
||||||
|
"log-lifecycle",
|
||||||
"livesync-batch",
|
"livesync-batch",
|
||||||
"received-change-readiness",
|
"received-change-readiness",
|
||||||
"internal-metadata-doctor",
|
"internal-metadata-doctor",
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.0.42
|
||||||
|
|
||||||
27th October, 2026
|
27th October, 2026
|
||||||
|
|||||||
Reference in new issue
Block a user