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
10 changes: 10 additions & 0 deletions src/runtime/reconciliationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ export class ReconciliationController {
}
}

isDiskAuthorityRecoveryActive(path: string): boolean {
const lockUntil = this.boundRecoveryLocks.get(path);
if (!lockUntil) return false;
if (Date.now() > lockUntil) {
this.boundRecoveryLocks.delete(path);
return false;
}
return true;
}

async runReconciliation(mode: ReconcileMode): Promise<void> {
const vaultSync = this.deps.getVaultSync();
const diskMirror = this.deps.getDiskMirror();
Expand Down
50 changes: 47 additions & 3 deletions src/sync/diskMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export class DiskMirror {
private drainPromise: Promise<void> | null = null;
private pathWriteLocks = new Map<string, Promise<void>>();

/**
* Tracks recent local-repair writes to CRDT. When a provider echo arrives
* with matching content, we skip scheduling a disk write to avoid the
* S-A race window.
*/
private recentRepairEchoes = new Map<string, { contentHash: string; expiresAt: number }>();

/** Per-file Y.Text observers. Only attached for open/active files. */
private textObservers = new Map<
string,
Expand Down Expand Up @@ -145,6 +152,32 @@ export class DiskMirror {
this._flightEventHandler = handler;
}

/**
* Record a local-repair write to CRDT so that its provider echo can be suppressed.
* TTL is SUPPRESS_MS + DEBOUNCE_BURST_MS (typically 1.5s).
*/
async recordRepairEcho(path: string, content: string): Promise<void> {
const normalized = normalizePath(path);
const hash = await contentBaselineHash(content);
this.recentRepairEchoes.set(normalized, {
contentHash: hash,
expiresAt: Date.now() + SUPPRESS_MS + DEBOUNCE_BURST_MS,
});
if (this.debug) {
this.log(`recordRepairEcho: registered for "${normalized}" (hash=${hashPrefix(hash)})`);
}
}

private getActiveRepairEcho(path: string): { contentHash: string } | null {
const entry = this.recentRepairEchoes.get(path);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.recentRepairEchoes.delete(path);
return null;
}
return entry;
}

/**
* Register a callback that fires after every successful `flushWrite`.
* The callback receives the normalized path and the SHA-256 hash of the
Expand Down Expand Up @@ -212,7 +245,7 @@ export class DiskMirror {
// reverse-maps them to paths, and schedules writes for any path
// that doesn't already have a per-file observer (i.e. closed).
// ---------------------------------------------------------------
const afterTxnHandler = (txn: Y.Transaction) => {
const afterTxnHandler = async (txn: Y.Transaction) => {
if (isLocalOrigin(txn.origin, this.vaultSync.provider)) return;

for (const [changedType] of txn.changed) {
Expand All @@ -228,8 +261,19 @@ export class DiskMirror {

const path = meta.path;

// Skip if this path is already open (handled by per-file observer policy)
if (this.openPaths.has(path)) continue;
// Check if this is a provider echo of a recent local repair.
const echo = this.getActiveRepairEcho(path);
if (echo) {
const crdtContent = changedType.toJSON();
const crdtHash = await contentBaselineHash(crdtContent);
if (crdtHash === echo.contentHash) {
this.log(`afterTxn: skipping echo disk write for repair on "${path}"`);
continue;
}
}

// Skip if this path is already open (handled by per-file observer policy)
if (this.openPaths.has(path)) continue;

this.log(`afterTxn: remote content change to closed file "${path}"`);
this.scheduleWrite(path);
Expand Down
13 changes: 12 additions & 1 deletion src/sync/editorBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,12 @@ export class EditorBindingManager {
});
}

heal(view: MarkdownView, deviceName: string, reason: string): boolean {
heal(
view: MarkdownView,
deviceName: string,
reason: string,
isDiskAuthorityRecoveryActive?: (path: string) => boolean,
): boolean {
this.lastDeviceName = deviceName;
const file = view.file;
if (!file) return false;
Expand All @@ -308,6 +313,12 @@ export class EditorBindingManager {
return this.isHardTombstonedPath(file.path);
}

// New guard: reject heal during active disk-authority recovery
if (isDiskAuthorityRecoveryActive?.(file.path)) {
this.log(`heal: skipped for "${file.path}" (disk-authority recovery active, reason=${reason})`);
return this.repair(view, deviceName, reason);
}

const currentContent = view.editor.getValue();
const crdtContent = target.ytext.toJSON();
if (crdtContent !== currentContent) {
Expand Down
2 changes: 1 addition & 1 deletion tests/editor-binding-health-regressions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ console.log("\n--- Test 4: editor-health-heal origin remains manual-only ---");
{
const healSection = sliceBetween(
bindingSource,
"heal(view: MarkdownView, deviceName: string, reason: string): boolean {",
"heal(",
"rebind(view: MarkdownView, deviceName: string, reason: string): void {",
);
assert(healSection !== null, "heal section found");
Expand Down
164 changes: 164 additions & 0 deletions tests/inv-edit-02-heal-after-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* INV-EDIT-02 — heal() contamination after recovery integration test.
*/

import * as Y from "yjs";
import { ReconciliationController } from "../src/runtime/reconciliationController";
import { EditorBindingManager } from "../src/sync/editorBinding";
import { TFile, MarkdownView } from "obsidian";

let passed = 0;
let failed = 0;

function assert(condition: boolean, msg: string) {
if (condition) {
console.log(` PASS ${msg}`);
passed++;
} else {
console.error(` FAIL ${msg}`);
failed++;
}
}

function makeTFile(path: string): TFile {
const file = new TFile() as TFile & { path: string };
file.path = path;
return file;
}

// ── Test 1: heal() contamination (S-B) — FIXED ────────────────────────────────
console.log("\n--- Test 1: heal() contamination (S-B) — FIXED ---");
(async () => {
const path = "test.md";
const diskContent = "v2 (disk)";
const staleCrdt = "v1 (stale)";
const staleEditor = "v1 (stale)";

const doc = new Y.Doc();
const ytext = doc.getText("content");
ytext.insert(0, staleCrdt);

const file = makeTFile(path);
const fakeCm = {
state: {
field: () => ({}),
doc: { length: staleEditor.length },
},
dispatch: () => {},
};
const view = {
file,
editor: {
getValue: () => staleEditor,
cm: fakeCm,
},
leaf: { id: "leaf-1" },
} as any;
Object.setPrototypeOf(view, MarkdownView.prototype);

const fakeVaultSync = {
provider: {
__kind: "fake-provider",
awareness: {
setLocalStateField: () => {},
getLocalState: () => ({}),
on: () => {},
off: () => {},
},
},
ydoc: doc,
getTextForPath: () => ytext,
getFileIdForText: () => "file-1",
getFileId: () => "file-1",
serverAckTracker: { withActiveOpId: (_id: any, cb: any) => cb() },
ensureFile: (path: string, content: string) => {
ytext.delete(0, ytext.length);
ytext.insert(0, content);
return ytext;
},
};

const editorBindings = new EditorBindingManager(
fakeVaultSync as any,
false,
);
(editorBindings as any).getCmView = () => fakeCm;
editorBindings.bind(view as any, "device");
const b = (editorBindings as any).bindings.get("leaf-1");
if (b) b.lastEditorChangeAtMs = 0;

const app = {
vault: {
read: async () => diskContent,
adapter: {
stat: async () => ({ mtime: 10, size: diskContent.length }),
},
getAbstractFileByPath: (p: string) => (p === path ? file : null),
},
workspace: {
iterateAllLeaves: (cb: any) => {
cb({ view });
},
},
};

const controller = new ReconciliationController({
app: app as any,
getSettings: () => ({ deviceName: "device" }) as any,
getRuntimeConfig: () =>
({
maxFileSizeBytes: 0,
maxFileSizeKB: 0,
excludePatterns: [],
externalEditPolicy: "always",
}) as any,
getVaultSync: () => fakeVaultSync as any,
getDiskMirror: () =>
({
updateDiskIndexForPath: async () => {},
isPreservedUnresolved: () => false,
recordRepairEcho: async () => {},
}) as any,
getBlobSync: () => null,
getEditorBindings: () => editorBindings as any,
getDiskIndex: () => ({}),
setDiskIndex: () => {},
isMarkdownPathSyncable: () => true,
shouldBlockFrontmatterIngest: () => false,
refreshServerCapabilities: async () => {},
validateOpenEditorBindings: () => {},
onReconciled: () => {},
getAwaitingFirstProviderSyncAfterStartup: () => false,
setAwaitingFirstProviderSyncAfterStartup: () => {},
saveDiskIndex: async () => {},
refreshStatusBar: () => {},
trace: () => {},
scheduleTraceStateSnapshot: () => {},
log: () => {},
});

// Trigger disk-authority recovery (IDLE branch)
// We use "v2 (disk)" on disk, "v1 (stale)" in CRDT and Editor.
await (controller as any).syncFileFromDisk(file, "modify");

assert(
ytext.toString() === diskContent,
`after recovery: CRDT equals disk content (content="${ytext.toString()}")`,
);

const isActive = controller.isDiskAuthorityRecoveryActive(path);
assert(isActive, "recovery lock is active");

// Simulate concurrent heal() call in the same tick.
editorBindings.heal(view as any, "device", "concurrent-check", (p) =>
controller.isDiskAuthorityRecoveryActive(p),
);

assert(
ytext.toString() === diskContent,
`SUCCESS: heal() skipped overwrite (content is still "${ytext.toString()}")`,
);

doc.destroy();
process.exit(failed > 0 ? 1 : 0);
})();
Loading