Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions src/runtime/waitForLayoutReady.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (workspace.layoutReady) return Promise.resolve();
return new Promise<void>((resolve) => {
workspace.onLayoutReady(() => resolve());
});
}
82 changes: 82 additions & 0 deletions tests/client/wait-for-layout-ready.ts
Original file line number Diff line number Diff line change
@@ -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();