diff --git a/esbuild.config.mjs b/esbuild.config.mjs index e2e16e8e..190557ef 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -12,6 +12,10 @@ import { terserOption } from "./terser.config.mjs"; import path from "node:path"; const prod = process.argv[2] === "production" || process.env?.BUILD_MODE === "production"; +const issue1189Variant = process.env.ISSUE_1189_VARIANT ?? "normal"; +if (issue1189Variant !== "normal" && issue1189Variant !== "serial") { + throw new Error("ISSUE_1189_VARIANT must be 'normal' or 'serial'."); +} const keepTest = true; //!prod; const manifestJson = JSON.parse(fs.readFileSync("./manifest.json") + ""); @@ -181,6 +185,7 @@ const context = await esbuild.context({ entryPoints: ["src/main.ts"], bundle: true, define: { + ISSUE_1189_VARIANT: JSON.stringify(issue1189Variant), MANIFEST_VERSION: `"${manifestJson.version}"`, PACKAGE_VERSION: `"${packageJson.version}"`, UPDATE_INFO: `${updateInfo}`, diff --git a/src/main.ts b/src/main.ts index 7ea2f730..22aa01a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,9 +51,13 @@ import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/inter import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts"; import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle"; +import { createIssue1189Diagnostics, type Issue1189Diagnostics, type Issue1189Variant } from "./serviceFeatures/issue1189Diagnostics"; +declare const ISSUE_1189_VARIANT: Issue1189Variant; export type LiveSyncCore = LiveSyncBaseCore; export default class ObsidianLiveSyncPlugin extends Plugin { core: LiveSyncCore; + issue1189Diagnostics?: Issue1189Diagnostics; + private stopIssue1189Timer?: () => void; /** * Initialise service modules. @@ -174,6 +178,49 @@ export default class ObsidianLiveSyncPlugin extends Plugin { return addOns; }, (core) => { + const diagnostics = createIssue1189Diagnostics( + core.services.setting, + core.services.API.getSystemVaultName(), + ISSUE_1189_VARIANT + ); + this.issue1189Diagnostics = diagnostics; + core.services.appLifecycle.onLoaded.addHandler(() => { + this.stopIssue1189Timer = diagnostics.startTimer(window.setTimeout.bind(window), window.clearTimeout.bind(window)); + core.services.API.addCommand({ + id: "copy-issue-1189-diagnostics", + name: "Copy diagnostic record", + callback: () => { + if (!navigator.clipboard) { + new Notice("Could not copy the diagnostic record."); + return; + } + void navigator.clipboard.writeText(diagnostics.report()).then( + () => new Notice("Diagnostic record copied."), + () => new Notice("Could not copy the diagnostic record.") + ); + }, + }); + core.services.API.addCommand({ + id: "save-issue-1189-diagnostics", + name: "Save diagnostic record", + callback: () => { + const path = `${this.manifest.dir}/issue-1189-diagnostics.json`; + void this.app.vault.adapter.write(path, diagnostics.report()).then( + () => new Notice("Diagnostic record saved in the plug-in folder."), + () => new Notice("Could not save the diagnostic record.") + ); + }, + }); + core.services.API.addCommand({ + id: "resume-issue-1189-fast-setup", + name: "Resume diagnostic attempt", + callback: () => { + diagnostics.allowResume(); + core.services.appLifecycle.performRestart(); + }, + }); + return Promise.resolve(true); + }); //TODO Fix: useXXXX const featuresInitialiser = enableI18nFeature; const curriedFeature = () => featuresInitialiser(core); @@ -196,8 +243,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin { useSetupQRCodeFeature(core); useSetupURIFeature(core); useSetupManagerHandlersFeature(core, setupManager); - useOfflineScanner(core); - useRedFlagFeatures(core); + useOfflineScanner(core, { + fileConcurrency: ISSUE_1189_VARIANT === "serial" ? 1 : 10, + onFileActivity: (activity) => diagnostics.onFileActivity(activity), + }); + useRedFlagFeatures(core, diagnostics); useCheckRemoteSize(core); useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => { return new ConflictResolveModal(this.app, filename, conflictCheckResult); @@ -224,6 +274,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { void this._startUp(); } override onunload(): void { + this.stopIssue1189Timer?.(); return void this.core.services.control.onUnload(); } } diff --git a/src/modules/services/ObsidianAppLifecycleService.ts b/src/modules/services/ObsidianAppLifecycleService.ts index e2888a7a..8de675c8 100644 --- a/src/modules/services/ObsidianAppLifecycleService.ts +++ b/src/modules/services/ObsidianAppLifecycleService.ts @@ -10,6 +10,7 @@ declare module "obsidian" { // InjectableAppLifecycleService export class ObsidianAppLifecycleService extends AppLifecycleServiceBase { performRestart(): void { + this.context.liveSyncPlugin.issue1189Diagnostics?.markRestartRequested(); void this.context.plugin.app.commands.executeCommandById("app:reload"); } } diff --git a/src/serviceFeatures/issue1189Diagnostics.ts b/src/serviceFeatures/issue1189Diagnostics.ts new file mode 100644 index 00000000..d4e9de5a --- /dev/null +++ b/src/serviceFeatures/issue1189Diagnostics.ts @@ -0,0 +1,178 @@ +export type Issue1189Variant = "normal" | "serial"; +export type Issue1189Phase = "boot" | "fetch" | "reflect" | "finalise" | "complete" | "paused"; + +type DiagnosticEvent = { at: number; phase: Issue1189Phase; note?: string }; +type DiagnosticRun = { + id: string; + variant: Issue1189Variant; + startedAt: number; + updatedAt: number; + phase: Issue1189Phase; + status: "active" | "complete" | "paused"; + restartRequested: boolean; + activeFiles: number; + peakActiveFiles: number; + activeBytes: number; + peakActiveBytes: number; + processedFiles: number; + largestFile: number; + peakTimerDelayMs: number; + events: DiagnosticEvent[]; +}; + +type DiagnosticRecord = { + schema: 1; + current: DiagnosticRun; + previous?: DiagnosticRun; + resumeAllowed: boolean; +}; + +export interface Issue1189DiagnosticStorage { + getDeviceLocalConfig(key: string): string | null; + setDeviceLocalConfig(key: string, value: string): void; +} + +const INTERVAL_MS = 1000; +const HISTORY_LIMIT = 24; + +function parseRecord(raw: string | null): DiagnosticRecord | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as DiagnosticRecord; + if (parsed?.schema === 1 && parsed.current?.events instanceof Array) return parsed; + } catch { + // A damaged optional diagnostic record must never prevent plug-in startup. + } + return undefined; +} + +export function createIssue1189Diagnostics( + storage: Issue1189DiagnosticStorage, + vaultName: string, + variant: Issue1189Variant, + now: () => number = Date.now +) { + const key = `issue-1189-diagnostics-${vaultName}`; + let raw: string | null = null; + try { + raw = storage.getDeviceLocalConfig(key); + } catch { + // Diagnosis is optional when the browser's local store is unavailable. + } + const saved = parseRecord(raw); + const previous = + (saved?.current.phase === "boot" || saved?.current.phase === "paused") && saved.previous + ? saved.previous + : saved?.current; + const resumeAllowed = saved?.resumeAllowed === true; + const startedAt = now(); + const current: DiagnosticRun = { + id: `${startedAt}-${Math.random().toString(36).slice(2, 8)}`, + variant, + startedAt, + updatedAt: startedAt, + phase: "boot", + status: "active", + restartRequested: false, + activeFiles: 0, + peakActiveFiles: 0, + activeBytes: 0, + peakActiveBytes: 0, + processedFiles: 0, + largestFile: 0, + peakTimerDelayMs: 0, + events: [{ at: startedAt, phase: "boot" }], + }; + const record: DiagnosticRecord = { schema: 1, current, previous, resumeAllowed: false }; + let lastPersistedAt = 0; + let lastStorageError = ""; + const persist = () => { + current.updatedAt = now(); + try { + storage.setDeviceLocalConfig(key, JSON.stringify(record)); + lastPersistedAt = current.updatedAt; + lastStorageError = ""; + } catch (error) { + lastStorageError = error instanceof Error ? error.name : String(error); + } + }; + const event = (phase: Issue1189Phase, note?: string) => { + current.phase = phase; + current.events.push({ at: now(), phase, note }); + if (current.events.length > HISTORY_LIMIT) current.events.shift(); + persist(); + }; + persist(); + + return { + variant, + shouldPauseFetch: () => + !resumeAllowed && + previous?.status === "active" && + !previous.restartRequested && + (previous.phase === "fetch" || previous.phase === "reflect" || previous.phase === "finalise"), + pause() { + current.status = "paused"; + event("paused", "An earlier Fast Setup attempt did not finish."); + }, + allowResume() { + record.resumeAllowed = true; + persist(); + }, + markPhase: event, + markRestartRequested() { + current.restartRequested = true; + persist(); + }, + markComplete() { + current.status = "complete"; + event("complete"); + }, + onFileActivity({ phase, size }: { phase: "start" | "finish"; size: number }) { + const firstFile = current.activeFiles === 0 && current.processedFiles === 0; + const previousPeakFiles = current.peakActiveFiles; + const previousLargestFile = current.largestFile; + if (phase === "start") { + current.activeFiles++; + current.activeBytes += size; + current.peakActiveFiles = Math.max(current.peakActiveFiles, current.activeFiles); + current.peakActiveBytes = Math.max(current.peakActiveBytes, current.activeBytes); + current.largestFile = Math.max(current.largestFile, size); + } else { + current.activeFiles = Math.max(0, current.activeFiles - 1); + current.activeBytes = Math.max(0, current.activeBytes - size); + current.processedFiles++; + } + if ( + (phase === "start" && + (firstFile || current.peakActiveFiles > previousPeakFiles || (size >= 4_000_000 && size > previousLargestFile))) || + now() - lastPersistedAt >= INTERVAL_MS + ) { + persist(); + } + }, + startTimer(setTimer: (callback: () => void, delay: number) => number, clearTimer: (id: number) => void) { + let expected = now() + INTERVAL_MS; + let stopped = false; + let timerId = 0; + const tick = () => { + if (stopped) return; + const actual = now(); + current.peakTimerDelayMs = Math.max(current.peakTimerDelayMs, Math.max(0, actual - expected)); + if (actual - lastPersistedAt >= INTERVAL_MS) persist(); + expected = actual + INTERVAL_MS; + timerId = setTimer(tick, INTERVAL_MS); + }; + timerId = setTimer(tick, INTERVAL_MS); + return () => { + stopped = true; + clearTimer(timerId); + }; + }, + report() { + return JSON.stringify({ ...record, storageError: lastStorageError || undefined }, null, 2); + }, + }; +} + +export type Issue1189Diagnostics = ReturnType; diff --git a/src/serviceFeatures/issue1189Diagnostics.unit.spec.ts b/src/serviceFeatures/issue1189Diagnostics.unit.spec.ts new file mode 100644 index 00000000..86280db4 --- /dev/null +++ b/src/serviceFeatures/issue1189Diagnostics.unit.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { createIssue1189Diagnostics } from "./issue1189Diagnostics"; + +function makeStorage() { + const items = new Map(); + return { + items, + getDeviceLocalConfig: (key: string) => items.get(key) ?? null, + setDeviceLocalConfig: (key: string, value: string) => { + items.set(key, value); + }, + }; +} + +describe("Issue 1189 diagnostic record", () => { + it("keeps an interrupted reflection readable across startup and pauses its retry", () => { + const storage = makeStorage(); + let clock = 1000; + const first = createIssue1189Diagnostics(storage, "Vault", "normal", () => clock); + first.markPhase("fetch"); + first.markPhase("reflect"); + first.onFileActivity({ phase: "start", size: 40_000_000 }); + + clock += 1000; + const second = createIssue1189Diagnostics(storage, "Vault", "normal", () => clock); + expect(second.shouldPauseFetch()).toBe(true); + second.pause(); + const record = JSON.parse(second.report()); + expect(record.previous.phase).toBe("reflect"); + expect(record.previous.peakActiveBytes).toBe(40_000_000); + + clock += 1000; + const third = createIssue1189Diagnostics(storage, "Vault", "serial", () => clock); + expect(third.shouldPauseFetch()).toBe(true); + third.allowResume(); + clock += 1000; + const resumed = createIssue1189Diagnostics(storage, "Vault", "serial", () => clock); + expect(resumed.shouldPauseFetch()).toBe(false); + }); + + it("does not pause after a requested restart or completed attempt", () => { + const storage = makeStorage(); + const first = createIssue1189Diagnostics(storage, "Vault", "normal"); + first.markPhase("fetch"); + first.markRestartRequested(); + expect(createIssue1189Diagnostics(storage, "Vault", "normal").shouldPauseFetch()).toBe(false); + + const completed = createIssue1189Diagnostics(storage, "Other Vault", "serial"); + completed.markPhase("finalise"); + completed.markComplete(); + expect(createIssue1189Diagnostics(storage, "Other Vault", "serial").shouldPauseFetch()).toBe(false); + }); + + it("bounds the saved event history and measures timer delay", () => { + const storage = makeStorage(); + let clock = 0; + const diagnostics = createIssue1189Diagnostics(storage, "Vault", "normal", () => clock); + for (let i = 0; i < 40; i++) diagnostics.markPhase("fetch"); + let tick: (() => void) | undefined; + const stop = diagnostics.startTimer((callback) => { + tick = callback; + return 1; + }, () => {}); + clock = 1500; + tick?.(); + stop(); + const record = JSON.parse(diagnostics.report()); + expect(record.current.events).toHaveLength(24); + expect(record.current.peakTimerDelayMs).toBe(500); + }); +}); diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 1578ab1c..452bb550 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -11,6 +11,7 @@ import { type FullScanOptions, } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { adjustSettingToRemoteIfNeeded, cancelScheduledInitialisation, processVaultInitialisation } from "./redFlag"; +import type { Issue1189Diagnostics } from "./issue1189Diagnostics"; export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer"; @@ -188,7 +189,8 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( "storageAccess" | "rebuilder" | "fileHandler" >, log: LogFunction, - cleanupFlag: () => Promise + cleanupFlag: () => Promise, + diagnostics?: Issue1189Diagnostics ): Promise { const result = await askSimpleFetchMode(host); if (result === "cancelled") { @@ -215,9 +217,11 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( const performFastSetup = async () => { // 1. Perform fast DB fetch (download remote DB content to local DB) + diagnostics?.markPhase("fetch", "downloading"); await host.serviceModules.rebuilder.$fetchLocalDBFast(false); // 2. Call the extended synchroniseAllFilesBetweenDBandStorage to reflect changes in storage + diagnostics?.markPhase("reflect"); const errorManager = new UnresolvedErrorManager(host.services.appLifecycle, host.services.context.events); const syncResult = await synchroniseAllFilesBetweenDBandStorage( host, @@ -228,6 +232,8 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( showingNotice: true, omitEvents: true, ignoreSuspending: true, + fileConcurrency: diagnostics?.variant === "serial" ? 1 : 10, + onFileActivity: diagnostics ? (activity) => diagnostics.onFileActivity(activity) : undefined, }) ); if (!syncResult) { @@ -244,9 +250,11 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( return false; } } + diagnostics?.markPhase("finalise"); await host.serviceModules.rebuilder.finishRebuild(); await cleanupFlag(); clearRememberedSimpleFetchMode(host); + diagnostics?.markComplete(); log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE); return true; }; diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index 40daa117..916116e8 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -19,6 +19,7 @@ import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; import { isP2PMainRemote } from "@/common/remoteConfiguration"; import { $msg } from "@/common/translation"; +import type { Issue1189Diagnostics } from "./issue1189Diagnostics"; /** * Flag file handler interface, similar to target filter pattern. @@ -128,7 +129,8 @@ export function createFetchAllFlagHandler( | "database", "storageAccess" | "rebuilder" | "fileHandler" >, - log: LogFunction + log: LogFunction, + diagnostics?: Issue1189Diagnostics ): FlagFileHandler { // Check if fetch all flag is active const isFlagActive = async () => @@ -143,6 +145,11 @@ export function createFetchAllFlagHandler( // Handle the fetch all scheduled operation const onScheduled = async () => { + if (diagnostics?.shouldPauseFetch()) { + diagnostics.pause(); + log("An earlier Fast Setup attempt stopped unexpectedly. Use 'Copy diagnostic record', then 'Resume diagnostic attempt' to continue.", LOG_LEVEL_NOTICE); + return false; + } // Select the remote database if there are multiple remotes configured. const isRemoteActivated = await askAndActivateRemoteDatabase(host, log); if (!isRemoteActivated) { @@ -150,7 +157,7 @@ export function createFetchAllFlagHandler( } // Ask user for use Fast Setup - const useFastSetup = await askAndPerformFastSetupOnScheduledFetchAll(host, log, cleanupFlag); + const useFastSetup = await askAndPerformFastSetupOnScheduledFetchAll(host, log, cleanupFlag, diagnostics); if (useFastSetup !== undefined) { return useFastSetup; } @@ -567,10 +574,11 @@ export function useRedFlagFeatures( | "keyValueDB" | "database", "storageAccess" | "rebuilder" | "fileHandler" - > + >, + diagnostics?: Issue1189Diagnostics ) { const log = createInstanceLogFunction("SF:RedFlag", host.services.API); - const handlerFetch = createFetchAllFlagHandler(host, log); + const handlerFetch = createFetchAllFlagHandler(host, log, diagnostics); const handlerRebuild = createRebuildFlagHandler(host, log); const handlerSuspend = createSuspendFlagHandler(host, log); host.services.appLifecycle.onLayoutReady.addHandler(flagHandlerToEventHandler(handlerFetch), handlerFetch.priority); diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index ff5bcb7c..c6fc164d 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -401,6 +401,20 @@ describe("Red Flag Feature", () => { }); describe("Fetch All Flag Handler", () => { + it("pauses an interrupted diagnostic attempt before fetching or deleting its flag", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + const diagnostics = { shouldPauseFetch: () => true, pause: vi.fn() }; + + const result = await createFetchAllFlagHandler(host as any, log, diagnostics as any).handle(); + + expect(result).toBe(false); + expect(diagnostics.pause).toHaveBeenCalledOnce(); + expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled(); + expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(true); + }); + it("should detect fetch all flag using original filename", async () => { const host = createHostMock(); const log = createLoggerMock(); diff --git a/updates.md b/updates.md index 9ab6bf94..e24136a2 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Diagnostics + +- Issue #1189 diagnostic builds keep a device-local record of Fast Setup progress across restarts. After an interrupted attempt, the automatic retry pauses so you can copy or save the record before trying again. + ## 1.0.30 18th September, 2026