Skip to content

Commit 66cd864

Browse files
committed
feat(sync): implement disk authority recovery checks and enhance healing logic; add integration tests for recovery scenarios
1 parent 3837c05 commit 66cd864

7 files changed

Lines changed: 422 additions & 6 deletions

src/runtime/reconciliationController.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,16 @@ export class ReconciliationController {
312312
}
313313
}
314314

315+
isDiskAuthorityRecoveryActive(path: string): boolean {
316+
const lockUntil = this.boundRecoveryLocks.get(path);
317+
if (!lockUntil) return false;
318+
if (Date.now() > lockUntil) {
319+
this.boundRecoveryLocks.delete(path);
320+
return false;
321+
}
322+
return true;
323+
}
324+
315325
async runReconciliation(mode: ReconcileMode): Promise<void> {
316326
const vaultSync = this.deps.getVaultSync();
317327
const diskMirror = this.deps.getDiskMirror();

src/sync/diskMirror.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ export class DiskMirror {
9494
private drainPromise: Promise<void> | null = null;
9595
private pathWriteLocks = new Map<string, Promise<void>>();
9696

97+
/**
98+
* Tracks recent local-repair writes to CRDT. When a provider echo arrives
99+
* with matching content, we skip scheduling a disk write to avoid the
100+
* S-A race window.
101+
*/
102+
private recentRepairEchoes = new Map<string, { contentHash: string; expiresAt: number }>();
103+
97104
/** Per-file Y.Text observers. Only attached for open/active files. */
98105
private textObservers = new Map<
99106
string,
@@ -145,6 +152,32 @@ export class DiskMirror {
145152
this._flightEventHandler = handler;
146153
}
147154

155+
/**
156+
* Record a local-repair write to CRDT so that its provider echo can be suppressed.
157+
* TTL is SUPPRESS_MS + DEBOUNCE_BURST_MS (typically 1.5s).
158+
*/
159+
async recordRepairEcho(path: string, content: string): Promise<void> {
160+
const normalized = normalizePath(path);
161+
const hash = await contentBaselineHash(content);
162+
this.recentRepairEchoes.set(normalized, {
163+
contentHash: hash,
164+
expiresAt: Date.now() + SUPPRESS_MS + DEBOUNCE_BURST_MS,
165+
});
166+
if (this.debug) {
167+
this.log(`recordRepairEcho: registered for "${normalized}" (hash=${hashPrefix(hash)})`);
168+
}
169+
}
170+
171+
private getActiveRepairEcho(path: string): { contentHash: string } | null {
172+
const entry = this.recentRepairEchoes.get(path);
173+
if (!entry) return null;
174+
if (Date.now() > entry.expiresAt) {
175+
this.recentRepairEchoes.delete(path);
176+
return null;
177+
}
178+
return entry;
179+
}
180+
148181
/**
149182
* Register a callback that fires after every successful `flushWrite`.
150183
* The callback receives the normalized path and the SHA-256 hash of the
@@ -212,7 +245,7 @@ export class DiskMirror {
212245
// reverse-maps them to paths, and schedules writes for any path
213246
// that doesn't already have a per-file observer (i.e. closed).
214247
// ---------------------------------------------------------------
215-
const afterTxnHandler = (txn: Y.Transaction) => {
248+
const afterTxnHandler = async (txn: Y.Transaction) => {
216249
if (isLocalOrigin(txn.origin, this.vaultSync.provider)) return;
217250

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

229262
const path = meta.path;
230263

231-
// Skip if this path is already open (handled by per-file observer policy)
232-
if (this.openPaths.has(path)) continue;
264+
// Check if this is a provider echo of a recent local repair.
265+
const echo = this.getActiveRepairEcho(path);
266+
if (echo) {
267+
const crdtContent = changedType.toJSON();
268+
const crdtHash = await contentBaselineHash(crdtContent);
269+
if (crdtHash === echo.contentHash) {
270+
this.log(`afterTxn: skipping echo disk write for repair on "${path}"`);
271+
continue;
272+
}
273+
}
274+
275+
// Skip if this path is already open (handled by per-file observer policy)
276+
if (this.openPaths.has(path)) continue;
233277

234278
this.log(`afterTxn: remote content change to closed file "${path}"`);
235279
this.scheduleWrite(path);

src/sync/editorBinding.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,12 @@ export class EditorBindingManager {
293293
});
294294
}
295295

296-
heal(view: MarkdownView, deviceName: string, reason: string): boolean {
296+
heal(
297+
view: MarkdownView,
298+
deviceName: string,
299+
reason: string,
300+
isDiskAuthorityRecoveryActive?: (path: string) => boolean,
301+
): boolean {
297302
this.lastDeviceName = deviceName;
298303
const file = view.file;
299304
if (!file) return false;
@@ -308,6 +313,12 @@ export class EditorBindingManager {
308313
return this.isHardTombstonedPath(file.path);
309314
}
310315

316+
// New guard: reject heal during active disk-authority recovery
317+
if (isDiskAuthorityRecoveryActive?.(file.path)) {
318+
this.log(`heal: skipped for "${file.path}" (disk-authority recovery active, reason=${reason})`);
319+
return this.repair(view, deviceName, reason);
320+
}
321+
311322
const currentContent = view.editor.getValue();
312323
const crdtContent = target.ytext.toJSON();
313324
if (crdtContent !== currentContent) {

tests/editor-binding-health-regressions.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ console.log("\n--- Test 4: editor-health-heal origin remains manual-only ---");
6666
{
6767
const healSection = sliceBetween(
6868
bindingSource,
69-
"heal(view: MarkdownView, deviceName: string, reason: string): boolean {",
69+
"heal(",
7070
"rebind(view: MarkdownView, deviceName: string, reason: string): void {",
7171
);
7272
assert(healSection !== null, "heal section found");
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* INV-EDIT-02 — heal() contamination after recovery integration test.
3+
*/
4+
5+
import * as Y from "yjs";
6+
import { ReconciliationController } from "../src/runtime/reconciliationController";
7+
import { EditorBindingManager } from "../src/sync/editorBinding";
8+
import { TFile, MarkdownView } from "obsidian";
9+
10+
let passed = 0;
11+
let failed = 0;
12+
13+
function assert(condition: boolean, msg: string) {
14+
if (condition) {
15+
console.log(` PASS ${msg}`);
16+
passed++;
17+
} else {
18+
console.error(` FAIL ${msg}`);
19+
failed++;
20+
}
21+
}
22+
23+
function makeTFile(path: string): TFile {
24+
const file = new TFile() as TFile & { path: string };
25+
file.path = path;
26+
return file;
27+
}
28+
29+
// ── Test 1: heal() contamination (S-B) — FIXED ────────────────────────────────
30+
console.log("\n--- Test 1: heal() contamination (S-B) — FIXED ---");
31+
(async () => {
32+
const path = "test.md";
33+
const diskContent = "v2 (disk)";
34+
const staleCrdt = "v1 (stale)";
35+
const staleEditor = "v1 (stale)";
36+
37+
const doc = new Y.Doc();
38+
const ytext = doc.getText("content");
39+
ytext.insert(0, staleCrdt);
40+
41+
const file = makeTFile(path);
42+
const fakeCm = {
43+
state: {
44+
field: () => ({}),
45+
doc: { length: staleEditor.length },
46+
},
47+
dispatch: () => {},
48+
};
49+
const view = {
50+
file,
51+
editor: {
52+
getValue: () => staleEditor,
53+
cm: fakeCm,
54+
},
55+
leaf: { id: "leaf-1" },
56+
} as any;
57+
Object.setPrototypeOf(view, MarkdownView.prototype);
58+
59+
const fakeVaultSync = {
60+
provider: {
61+
__kind: "fake-provider",
62+
awareness: {
63+
setLocalStateField: () => {},
64+
getLocalState: () => ({}),
65+
on: () => {},
66+
off: () => {},
67+
},
68+
},
69+
ydoc: doc,
70+
getTextForPath: () => ytext,
71+
getFileIdForText: () => "file-1",
72+
getFileId: () => "file-1",
73+
serverAckTracker: { withActiveOpId: (_id: any, cb: any) => cb() },
74+
ensureFile: (path: string, content: string) => {
75+
ytext.delete(0, ytext.length);
76+
ytext.insert(0, content);
77+
return ytext;
78+
},
79+
};
80+
81+
const editorBindings = new EditorBindingManager(
82+
fakeVaultSync as any,
83+
false,
84+
);
85+
(editorBindings as any).getCmView = () => fakeCm;
86+
editorBindings.bind(view as any, "device");
87+
const b = (editorBindings as any).bindings.get("leaf-1");
88+
if (b) b.lastEditorChangeAtMs = 0;
89+
90+
const app = {
91+
vault: {
92+
read: async () => diskContent,
93+
adapter: {
94+
stat: async () => ({ mtime: 10, size: diskContent.length }),
95+
},
96+
getAbstractFileByPath: (p: string) => (p === path ? file : null),
97+
},
98+
workspace: {
99+
iterateAllLeaves: (cb: any) => {
100+
cb({ view });
101+
},
102+
},
103+
};
104+
105+
const controller = new ReconciliationController({
106+
app: app as any,
107+
getSettings: () => ({ deviceName: "device" }) as any,
108+
getRuntimeConfig: () =>
109+
({
110+
maxFileSizeBytes: 0,
111+
maxFileSizeKB: 0,
112+
excludePatterns: [],
113+
externalEditPolicy: "always",
114+
}) as any,
115+
getVaultSync: () => fakeVaultSync as any,
116+
getDiskMirror: () =>
117+
({
118+
updateDiskIndexForPath: async () => {},
119+
isPreservedUnresolved: () => false,
120+
recordRepairEcho: async () => {},
121+
}) as any,
122+
getBlobSync: () => null,
123+
getEditorBindings: () => editorBindings as any,
124+
getDiskIndex: () => ({}),
125+
setDiskIndex: () => {},
126+
isMarkdownPathSyncable: () => true,
127+
shouldBlockFrontmatterIngest: () => false,
128+
refreshServerCapabilities: async () => {},
129+
validateOpenEditorBindings: () => {},
130+
onReconciled: () => {},
131+
getAwaitingFirstProviderSyncAfterStartup: () => false,
132+
setAwaitingFirstProviderSyncAfterStartup: () => {},
133+
saveDiskIndex: async () => {},
134+
refreshStatusBar: () => {},
135+
trace: () => {},
136+
scheduleTraceStateSnapshot: () => {},
137+
log: () => {},
138+
});
139+
140+
// Trigger disk-authority recovery (IDLE branch)
141+
// We use "v2 (disk)" on disk, "v1 (stale)" in CRDT and Editor.
142+
await (controller as any).syncFileFromDisk(file, "modify");
143+
144+
assert(
145+
ytext.toString() === diskContent,
146+
`after recovery: CRDT equals disk content (content="${ytext.toString()}")`,
147+
);
148+
149+
const isActive = controller.isDiskAuthorityRecoveryActive(path);
150+
assert(isActive, "recovery lock is active");
151+
152+
// Simulate concurrent heal() call in the same tick.
153+
editorBindings.heal(view as any, "device", "concurrent-check", (p) =>
154+
controller.isDiskAuthorityRecoveryActive(p),
155+
);
156+
157+
assert(
158+
ytext.toString() === diskContent,
159+
`SUCCESS: heal() skipped overwrite (content is still "${ytext.toString()}")`,
160+
);
161+
162+
doc.destroy();
163+
process.exit(failed > 0 ? 1 : 0);
164+
})();

0 commit comments

Comments
 (0)