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();