mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
resync the metadata cache on websocket reconnect
This commit is contained in:
@@ -18,7 +18,13 @@ const contentCache = new ContentCache();
|
|||||||
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
||||||
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||||
const fsWatch = createFsWatch(transport);
|
const fsWatch = createFsWatch(transport);
|
||||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
const watcherClient = createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
);
|
||||||
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
||||||
const fsCallbacks = createFsCallbacks(fsPromises);
|
const fsCallbacks = createFsCallbacks(fsPromises);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export class MetadataCache {
|
|||||||
return this._entries.size;
|
return this._entries.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalized keys of every entry, for callers that diff the cache against a fresh tree.
|
||||||
|
keys() {
|
||||||
|
return [...this._entries.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
toStat(path) {
|
toStat(path) {
|
||||||
const meta = this.get(path);
|
const meta = this.get(path);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
||||||
|
|
||||||
import { isRecentLocalOp } from "./echo-guard.js";
|
import { isRecentLocalOp } from "./echo-guard.js";
|
||||||
|
import { normalize } from "../util/path.js";
|
||||||
|
|
||||||
export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
|
const RESYNC_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
export function createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
) {
|
||||||
function handleCreated(msg) {
|
function handleCreated(msg) {
|
||||||
const { path, stat } = msg;
|
const { path, stat } = msg;
|
||||||
|
|
||||||
@@ -72,6 +81,74 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
|||||||
wsClient.subscribe("modified", handleModified);
|
wsClient.subscribe("modified", handleModified);
|
||||||
wsClient.subscribe("deleted", handleDeleted);
|
wsClient.subscribe("deleted", handleDeleted);
|
||||||
|
|
||||||
|
// Re-derive the cache from a freshly fetched tree after a reconnect.
|
||||||
|
// Each delta runs through the live-event handlers, matching live behavior.
|
||||||
|
function reconcile(tree) {
|
||||||
|
const fresh = new Set(Object.keys(tree).map(normalize));
|
||||||
|
|
||||||
|
for (const [path, meta] of Object.entries(tree)) {
|
||||||
|
const existing = metadataCache.get(path);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
if (meta.type === "directory") {
|
||||||
|
handleFolderCreated({ path });
|
||||||
|
} else {
|
||||||
|
handleCreated({
|
||||||
|
path,
|
||||||
|
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
meta.type === "file" &&
|
||||||
|
(existing.mtime !== meta.mtime || existing.size !== meta.size)
|
||||||
|
) {
|
||||||
|
handleModified({
|
||||||
|
path,
|
||||||
|
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cache key absent from the fresh tree was deleted while disconnected.
|
||||||
|
// The empty root key is preserved because the tree never lists it.
|
||||||
|
for (const key of metadataCache.keys()) {
|
||||||
|
if (key === "" || fresh.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleted({ path: key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resync() {
|
||||||
|
let tree;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tree = await transport.fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[shim:fs] reconnect resync failed:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce a burst of reconnects into a single resync once the socket settles.
|
||||||
|
let resyncTimer = null;
|
||||||
|
|
||||||
|
function scheduleResync() {
|
||||||
|
if (resyncTimer) {
|
||||||
|
clearTimeout(resyncTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
resyncTimer = setTimeout(() => {
|
||||||
|
resyncTimer = null;
|
||||||
|
resync();
|
||||||
|
}, RESYNC_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
wsClient.onReconnect(scheduleResync);
|
||||||
|
|
||||||
function connect(vaultId) {
|
function connect(vaultId) {
|
||||||
wsClient.connect(vaultId);
|
wsClient.connect(vaultId);
|
||||||
}
|
}
|
||||||
@@ -83,5 +160,6 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
|||||||
return {
|
return {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
reconcile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
packages/shim/src/fs/watcher-client.test.js
Normal file
101
packages/shim/src/fs/watcher-client.test.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createWatcherClient } from "./watcher-client.js";
|
||||||
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
const metadataCache = {
|
||||||
|
get: (p) => store.get(p) || null,
|
||||||
|
set: (p, m) => store.set(p, m),
|
||||||
|
delete: (p) => store.delete(p),
|
||||||
|
has: (p) => store.has(p),
|
||||||
|
keys: () => [...store.keys()],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentCache = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
get: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fsWatch = { _dispatch: vi.fn() };
|
||||||
|
const wsClient = { subscribe: vi.fn(), onReconnect: vi.fn() };
|
||||||
|
const transport = { fetchTree: vi.fn() };
|
||||||
|
|
||||||
|
const client = createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { store, metadataCache, contentCache, fsWatch, wsClient, transport, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("watcher-client reconcile", () => {
|
||||||
|
it("adds a file present in the tree but missing from the cache", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
|
||||||
|
d.client.reconcile({ "new.md": { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||||
|
|
||||||
|
expect(d.store.get("new.md")).toMatchObject({ type: "file", size: 5 });
|
||||||
|
expect(d.contentCache.invalidate).toHaveBeenCalledWith("new.md");
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("created", "new.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a directory as a folder", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
|
||||||
|
d.client.reconcile({ newdir: { type: "directory" } });
|
||||||
|
|
||||||
|
expect(d.store.get("newdir")).toEqual({ type: "directory" });
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("folder-created", "newdir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("modifies a file whose mtime or size changed", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "a.md": { type: "file", size: 2, mtime: 20, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.store.get("a.md")).toMatchObject({ size: 2, mtime: 20 });
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("modified", "a.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an unchanged file", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "a.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a cache entry absent from the tree and preserves the root", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("", { type: "directory" });
|
||||||
|
d.store.set("gone.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
d.store.set("keep.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "keep.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.store.has("gone.md")).toBe(false);
|
||||||
|
expect(d.store.has("")).toBe(true);
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("deleted", "gone.md");
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalledWith("deleted", "keep.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a path with a recent local op", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
const p = "recent-local-op-reconcile.md";
|
||||||
|
markLocalOp(p);
|
||||||
|
|
||||||
|
d.client.reconcile({ [p]: { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||||
|
|
||||||
|
expect(d.store.has(p)).toBe(false);
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,12 +8,14 @@ export function createWsClient() {
|
|||||||
let vaultId = null;
|
let vaultId = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let manuallyClosed = false;
|
let manuallyClosed = false;
|
||||||
|
let hasConnectedBefore = false;
|
||||||
let state = "closed"; // "closed" | "connecting" | "open"
|
let state = "closed"; // "closed" | "connecting" | "open"
|
||||||
|
|
||||||
const globalSubs = new Map(); // type -> Set<handler>
|
const globalSubs = new Map(); // type -> Set<handler>
|
||||||
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
||||||
const channelSubCount = new Map(); // channelName -> integer
|
const channelSubCount = new Map(); // channelName -> integer
|
||||||
const stateSubs = new Set(); // handler(state)
|
const stateSubs = new Set(); // handler(state)
|
||||||
|
const reconnectSubs = new Set(); // handler() fired on a re-open, not the first open
|
||||||
|
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
if (state === next) {
|
if (state === next) {
|
||||||
@@ -107,6 +109,20 @@ export function createWsClient() {
|
|||||||
for (const name of channelSubCount.keys()) {
|
for (const name of channelSubCount.keys()) {
|
||||||
sendSubscribeChannel(name);
|
sendSubscribeChannel(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A re-open can miss watcher events that fired while the socket was down.
|
||||||
|
// Boot covers the first open, so handlers fire only on later opens.
|
||||||
|
if (hasConnectedBefore) {
|
||||||
|
for (const fn of reconnectSubs) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ws] reconnect subscriber threw:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasConnectedBefore = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -252,6 +268,14 @@ export function createWsClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReconnect(handler) {
|
||||||
|
reconnectSubs.add(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reconnectSubs.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -260,6 +284,7 @@ export function createWsClient() {
|
|||||||
channel,
|
channel,
|
||||||
isOpen,
|
isOpen,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
|
onReconnect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
packages/shim/src/ws-client.test.js
Normal file
63
packages/shim/src/ws-client.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createWsClient } from "./ws-client.js";
|
||||||
|
|
||||||
|
let sockets;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sockets = [];
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = 0;
|
||||||
|
sockets.push(this);
|
||||||
|
}
|
||||||
|
send() {}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeWebSocket.OPEN = 1;
|
||||||
|
globalThis.WebSocket = FakeWebSocket;
|
||||||
|
globalThis.window = { location: { protocol: "http:", host: "localhost" } };
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete globalThis.WebSocket;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ws-client reconnect", () => {
|
||||||
|
it("fires onReconnect on a re-open but not on the first open", () => {
|
||||||
|
const client = createWsClient();
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
client.onReconnect(onReconnect);
|
||||||
|
|
||||||
|
client.connect("v1");
|
||||||
|
sockets[0].onopen();
|
||||||
|
expect(onReconnect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sockets[0].onclose();
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
sockets[1].onopen();
|
||||||
|
|
||||||
|
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops firing after unsubscribe", () => {
|
||||||
|
const client = createWsClient();
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
const off = client.onReconnect(onReconnect);
|
||||||
|
|
||||||
|
client.connect("v1");
|
||||||
|
sockets[0].onopen();
|
||||||
|
off();
|
||||||
|
|
||||||
|
sockets[0].onclose();
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
sockets[1].onopen();
|
||||||
|
|
||||||
|
expect(onReconnect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user