mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
#341 was only papered-over on RPC: the adapter fired no writer-side trigger; the daemon fs.watch echo *eventually* reached the model via FsChangeListener, but with a real race (editor could save to a dead path in the window). SFTP was fixed in #346; this makes self-reflect transport-independent so RPC is genuinely fixed too. - LocalOpRegistry (new): short-TTL path set the writer records into synchronously after each applied op (rename records old+new). The daemon echo can only arrive strictly later, so no record/echo race. - SftpDataAdapter: `applied(echoPaths, run)` helper folds the registry.record + the (exception-safe) reflect into one call at every applied-op site (write/writeBinary/appendBinary/mkdir/remove/ rmdir/rename), reusing the !reconnecting guard from #346/C1. - FsChangeListener: subscribe() takes an optional localOpRegistry (stored like lastPathMapper so reconnect-resume reuses the same instance); handleNotification drops a self-originated echo before invalidate+applyChange. One check on action.vaultPath covers every echo shape (incl. watchers that split rename into delete+create) because both old and new were recorded. Other clients' changes never pass through record, so multi-client propagation is intact. - AdapterManager: the reflector is now wired on BOTH transports plus a per-patch LocalOpRegistry; RPC additionally subscribes the listener with that registry. Removed applyWriterReflectPolicy / afterSwapClient (and the main.ts swapClient call): the old RPC=null-reflector strategy that I5/N3 patched is obsolete — the reflector is transport-invariant and the adapter object outlives swapClient, so wiring once in patch() holds for the patched lifetime; double-fire is prevented by the registry, not by nulling. Tests: self-reflect-rpc wires the reflector (now 4/4 green, docblock rewritten); LocalOpRegistry.test.ts (TTL/no-consume/prune/boundary); FsChangeListener echo-dedup unit (self drop / other-client apply / no-registry legacy); removed the now-obsolete AdapterManager afterSwapClient describe. Validation: tsc --noEmit clean, eslint clean, vitest 70 files / 1047 passed / 1 skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { LocalOpRegistry } from '../src/adapter/LocalOpRegistry';
|
|
|
|
/**
|
|
* Deterministic time control: the registry keys entirely off
|
|
* `Date.now()`, so a fake system clock makes TTL behaviour exact and
|
|
* non-flaky (no real sleeps).
|
|
*/
|
|
describe('LocalOpRegistry', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('reports a recorded path as self-originated within the TTL', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['Notes/a.md']);
|
|
expect(reg.isSelfOriginated('Notes/a.md')).toBe(true);
|
|
});
|
|
|
|
it('reports an unrecorded path as not self-originated', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['a.md']);
|
|
expect(reg.isSelfOriginated('b.md')).toBe(false);
|
|
});
|
|
|
|
it('records every path in the batch (rename → old + new)', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['old.md', 'new.md']);
|
|
expect(reg.isSelfOriginated('old.md')).toBe(true);
|
|
expect(reg.isSelfOriginated('new.md')).toBe(true);
|
|
});
|
|
|
|
it('does NOT consume the entry — repeated checks stay true (split rename echo)', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['x.md']);
|
|
expect(reg.isSelfOriginated('x.md')).toBe(true);
|
|
expect(reg.isSelfOriginated('x.md')).toBe(true); // delete echo + create echo
|
|
});
|
|
|
|
it('expires an entry once the TTL elapses', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['gone.md']);
|
|
vi.setSystemTime(1_001);
|
|
expect(reg.isSelfOriginated('gone.md')).toBe(false);
|
|
});
|
|
|
|
it('keeps an entry live right up to (not past) the TTL boundary', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['edge.md']);
|
|
vi.setSystemTime(1_000); // == expiresAt, Date.now() > expiresAt is false
|
|
expect(reg.isSelfOriginated('edge.md')).toBe(true);
|
|
});
|
|
|
|
it('prunes stale entries on a later record so the map cannot grow unbounded', () => {
|
|
const reg = new LocalOpRegistry(1_000);
|
|
reg.record(['stale.md']);
|
|
vi.setSystemTime(2_000);
|
|
reg.record(['fresh.md']); // triggers prune of stale.md
|
|
expect(reg.isSelfOriginated('stale.md')).toBe(false);
|
|
expect(reg.isSelfOriginated('fresh.md')).toBe(true);
|
|
});
|
|
|
|
it('defaults to a 5s TTL when none is supplied', () => {
|
|
const reg = new LocalOpRegistry();
|
|
reg.record(['d.md']);
|
|
vi.setSystemTime(4_999);
|
|
expect(reg.isSelfOriginated('d.md')).toBe(true);
|
|
vi.setSystemTime(5_001);
|
|
expect(reg.isSelfOriginated('d.md')).toBe(false);
|
|
});
|
|
});
|