From f867e41514b0cea1d8ead23f61ae6e2995b4c054 Mon Sep 17 00:00:00 2001 From: zamyb Date: Wed, 30 Sep 2026 17:08:48 +0700 Subject: [PATCH] fix: gate startup reconcile on workspace layout ready (#77) Startup reconciliation ran before Obsidian finished restoring the previous session's open panes. If a note was left open across a restart, isOpenOrBound had no MarkdownView leaf yet to find, so the closed-file planner treated an actively-edited note as closed. For a fresh note with no baseline hash, the missing-baseline branch then defaulted to CRDT wins and dumped the user's in-progress disk content into a conflict artifact. Add waitForWorkspaceLayoutReady(), a small awaitable wrapping Workspace.layoutReady / onLayoutReady, and gate the FIRST startup reconciliation on it in main.ts. This mirrors the identical pattern AttachmentOrchestrator already uses for its download gate. Reconnect reconciliation is left untouched since editor bindings are already live by then. Fixes #77 --- src/main.ts | 17 ++++++ src/runtime/waitForLayoutReady.ts | 43 ++++++++++++++ tests/client/wait-for-layout-ready.ts | 82 +++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/runtime/waitForLayoutReady.ts create mode 100644 tests/client/wait-for-layout-ready.ts diff --git a/src/main.ts b/src/main.ts index 4f7eb981..04af3938 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { isMarkdownSyncable, isBlobSyncable } from "./types"; import { planCategoryRenameAction } from "./sync/policy/renameAdmissionPolicy"; import { classifySyncPath } from "./paths/pathCategory"; import { isCanonicalPathFileIdCollision } from "./paths/pathCollision"; +import { waitForWorkspaceLayoutReady } from "./runtime/waitForLayoutReady"; import type { TraceSink } from "./observability/traceSink"; import type { FlightEventInput, FlightPathEventInput } from "./observability/flightEnvelope"; import { NoopTraceSink } from "./observability/noopTraceSink"; @@ -1045,6 +1046,22 @@ export default class VaultCrdtSyncPlugin extends Plugin { const mode = this.vaultSync.getSafeReconcileMode(); this.log(`Reconciliation mode: ${mode}`); + // Issue #77: wait for Obsidian to finish restoring the previous + // session's open panes before running the FIRST authoritative + // reconcile. Before this gate, a note left open across a restart had + // no MarkdownView leaf yet — isOpenOrBound was a false negative — so + // the closed-file planner treated a note the user was actively + // typing in as closed, and (with no baseline hash for a fresh note) + // dumped the in-progress disk content into a conflict artifact. + // Same pattern as AttachmentOrchestrator's download gate. + if (!this.app.workspace.layoutReady) { + this.log("Startup reconciliation: waiting for workspace layout to finish restoring..."); + this.trace("trace", "startup-reconcile-awaiting-layout-ready", {}); + await waitForWorkspaceLayoutReady(this.app.workspace); + if (abortIfStale("workspace layout ready")) return; + this.log("Startup reconciliation: workspace layout ready, proceeding"); + } + await this.runReconciliation(mode); if (abortIfStale("startup reconciliation")) return; this.reconciliationController.lastGeneration = this.vaultSync.connectionGeneration; diff --git a/src/runtime/waitForLayoutReady.ts b/src/runtime/waitForLayoutReady.ts new file mode 100644 index 00000000..681063e5 --- /dev/null +++ b/src/runtime/waitForLayoutReady.ts @@ -0,0 +1,43 @@ +/** + * Waits for Obsidian's workspace layout to finish restoring before letting + * startup reconciliation run. + * + * WHY THIS EXISTS (issue #77) + * + * On plugin load, `Workspace.layoutReady` is false until Obsidian has + * finished restoring the previous session's open panes/tabs. Startup + * reconciliation used to run immediately after IndexedDB/provider sync, + * without waiting for this. If the user had a note open and typing in it at + * the moment Obsidian relaunched, `isOpenOrBound` (main.ts / + * reconciliationController.ts, via `iterateAllLeaves` and + * `EditorBindingManager.isBound`) returned false — no MarkdownView leaf for + * that path existed yet — so the closed-file planner treated a genuinely + * open, actively-edited note as closed. + * + * For a freshly created note there is also no baseline hash yet + * (`decideClosedFileConflict`'s missing-baseline branch), so without mtime + * evidence the conservative distributed default (CRDT wins) fired: the + * disk content the user was actively typing got dumped into a + * "(YAOS conflict - disk from ...)" artifact instead of being recognized as + * live, in-progress content on this device. + * + * `AttachmentOrchestrator` already solved the identical race for the + * attachment download gate (see `attachmentOrchestrator.ts`'s + * `downloadGateLayoutReady` field). This helper generalizes the same + * `workspace.onLayoutReady` check into a small awaitable so + * `main.ts` can gate the FIRST startup reconciliation on it too, without + * delaying reconnect/live reconciliation (which is safe to run at any time + * because editor bindings are already live by then). + */ + +export interface WorkspaceLayoutReadyLike { + layoutReady: boolean; + onLayoutReady(callback: () => void): void; +} + +export function waitForWorkspaceLayoutReady(workspace: WorkspaceLayoutReadyLike): Promise { + if (workspace.layoutReady) return Promise.resolve(); + return new Promise((resolve) => { + workspace.onLayoutReady(() => resolve()); + }); +} diff --git a/tests/client/wait-for-layout-ready.ts b/tests/client/wait-for-layout-ready.ts new file mode 100644 index 00000000..5db8411d --- /dev/null +++ b/tests/client/wait-for-layout-ready.ts @@ -0,0 +1,82 @@ +/** + * Tests for waitForWorkspaceLayoutReady. + * + * Issue #77: startup reconciliation ran before Obsidian's workspace had + * restored open MarkdownView leaves, so isOpenOrBound (which reads + * app.workspace.iterateAllLeaves) was a false negative for a note the user + * was actively typing in. The closed-file planner then treated the note as + * closed and, with no baseline hash yet, defaulted to CRDT-wins — dumping + * the user's in-progress disk content into a conflict artifact. + * + * This helper gates the first startup reconciliation on + * workspace.layoutReady, mirroring the same pattern already used by + * AttachmentOrchestrator's download gate (src/runtime/attachmentOrchestrator.ts). + */ + +import { waitForWorkspaceLayoutReady } from "../../src/runtime/waitForLayoutReady"; +import { suite } from "../harness.ts"; + +const s = suite("wait-for-layout-ready"); + +interface FakeWorkspace { + layoutReady: boolean; + onLayoutReady: (cb: () => void) => void; +} + +function makeWorkspace(layoutReady: boolean): { workspace: FakeWorkspace; fire: () => void } { + let pending: (() => void) | null = null; + const workspace: FakeWorkspace = { + layoutReady, + onLayoutReady: (cb: () => void) => { + pending = cb; + }, + }; + return { workspace, fire: () => pending?.() }; +} + +s.section("Test 1: layout already ready resolves immediately, without registering a callback"); +{ + const { workspace, fire } = makeWorkspace(true); + let resolved = false; + const promise = waitForWorkspaceLayoutReady(workspace).then(() => { + resolved = true; + }); + await promise; + s.check(resolved, "resolves immediately when layoutReady is already true"); + // fire() is a no-op here since onLayoutReady was never called — this just + // documents that calling it is harmless if a test does so accidentally. + fire(); +} + +s.section("Test 2: layout not ready waits for onLayoutReady callback"); +{ + const { workspace, fire } = makeWorkspace(false); + let resolved = false; + const promise = waitForWorkspaceLayoutReady(workspace).then(() => { + resolved = true; + }); + + // Give the promise a microtask turn — it must NOT resolve yet. + await Promise.resolve(); + s.check(!resolved, "does not resolve before onLayoutReady fires"); + + fire(); + await promise; + s.check(resolved, "resolves once onLayoutReady fires"); +} + +s.section("Test 3: registers exactly one onLayoutReady callback per call"); +{ + let registrations = 0; + const workspace: FakeWorkspace = { + layoutReady: false, + onLayoutReady: (cb: () => void) => { + registrations++; + cb(); + }, + }; + await waitForWorkspaceLayoutReady(workspace); + s.check(registrations === 1, "onLayoutReady is registered exactly once"); +} + +await s.done();