Skip to content

fix: content-hash echo cache + heartbeat + directory rename#10

Merged
Go1c merged 1 commit intomainfrom
fix/websocket-echo-cache
Apr 14, 2026
Merged

fix: content-hash echo cache + heartbeat + directory rename#10
Go1c merged 1 commit intomainfrom
fix/websocket-echo-cache

Conversation

@Go1c
Copy link
Copy Markdown
Owner

@Go1c Go1c commented Apr 14, 2026

Summary

  • Heartbeat — re-enable the websockets lib's own ping with wide timeout (ping_interval=45, ping_timeout=90) so a genuine dead connection is still detected without false-positives during heavy chunk downloads. Fixes issue [Bug] 30秒没有更新之后,会自动断开连接 #9 (30s auto-disconnect).
  • Echo loop — replace the sleep-based ignore_file window with a content-hash echo cache, and move the 2s await asyncio.sleep(...) out of the WS receive handler (it serialized the receive loop and blew the 300s NoteSync timeout on initial sync). Cache is updated on both inbound and outbound, so a revert to an earlier value — and a delete after recreate — still propagate.
  • Directory renamewatcher.on_moved now enumerates the renamed tree and schedules per-file transitions, including the edge cases of moving into or out of an excluded path.

Context / new doc

doc/server-protocol.md is a new reference distilled from the fast-note-sync-service Go source — heartbeat protocol, broadcast mechanics, sync flow, and the specific client pitfalls we hit (including the ones this PR fixes).

Test plan

  • pytest -q — 23 passed (4 new regression tests in tests/test_echo_cache.py: revert-after-push, delete-after-recreate, NeedPush-force, failed-inbound-no-cache-poison; plus 2 watcher tests for excluded-path rename transitions in tests/test_file_sync.py).
  • Local bidirectional integration test (script not committed) against live server, exercising CRUD × {notes, attachments, folders} × {CLI→plugin, plugin→CLI}.
  • 3+ minutes of idle without spurious disconnects (previously failed around 60–90s).

Notes

  • client.heartbeat_interval config field is now unused; left in place to avoid breaking existing user configs.
  • Open follow-up: _echo_hashes has no eviction — fine for typical vault sizes, but worth bounding if someone runs this on a million-file vault.

Summary by Sourcery

Improve WebSocket heartbeat handling and file/note sync echo suppression while fixing directory move handling across ignored/excluded paths.

Bug Fixes:

  • Prevent premature WebSocket disconnects during heavy transfers by relying on the library heartbeat with generous ping settings instead of a custom inactivity watchdog.
  • Ensure renames, moves into, and out of excluded directories correctly generate delete/modify/rename events for each affected file, including platform-specific directory move behavior.
  • Fix missed pushes and dropped deletes when a file or note reverts to a previous value or is recreated after a server-driven delete.

Enhancements:

  • Introduce a content-hash-based echo cache for notes and files, updated on both inbound and outbound sync events, to suppress redundant pushes without blocking legitimate changes.
  • Simplify sync handlers by removing sleep-based ignore windows and basing echo suppression solely on durable on-disk state.
  • Clarify server heartbeat, broadcast behavior, and sync protocol details in a new server protocol reference document.

Documentation:

  • Add a server protocol reference documenting WebSocket heartbeat behavior, broadcast mechanics, sync flows, and known client pitfalls.

Tests:

  • Add regression tests covering the echo cache behavior for revert-after-push, delete-after-recreate, NeedPush forcing, and failure cases that must not poison the cache.
  • Add watcher tests verifying move handling into excluded paths and directory renames that should schedule per-file delete transitions.

Core changes across the sync stack:

* client.py — re-enable websockets lib's own ping with generous timeout
  (ping_interval=45, ping_timeout=90). Auto-pongs to the server's 25s
  pings keep its 60s deadline refreshed; the long timeout tolerates Pong
  delay behind large binary chunk writes that was tripping keepalive at
  30s and 60s during initial sync.

* note_sync.py / file_sync.py — replace the 0.6s/2s time-based ignore
  with a content-hash echo cache. The sleep-in-handler serialized the
  whole WS receive loop: 178 modifies × 2s ate the 300s sync window.
  _echo_hashes[path] now tracks the last known synced state and is
  updated on BOTH inbound (server write) and outbound (push) so a
  legitimate revert to a previous value still propagates. _DELETED
  sentinel for deletes, with the same outbound update so recreate +
  re-delete works. push_modify accepts force=True and NoteSyncNeedPush
  uses it to bypass the echo check.

* watcher.py — directory rename now enumerates the new tree and
  schedules per-file transitions. _schedule_move_transition handles the
  excluded-path edge cases: move into excluded → emit delete of old,
  move out of excluded → emit modify of new.

* doc/server-protocol.md — new reference distilled from the Go source:
  heartbeat protocol, broadcast mechanics, sync flow, and the specific
  client pitfalls we hit.

* tests — new test_echo_cache.py covers the revert, tombstone-reuse,
  NeedPush-force, and failed-write-no-poison cases; test_file_sync adds
  the excluded-path rename transitions.

23 unit tests pass. Bidirectional integration tests (not committed)
confirm notes, attachments, and folders sync correctly in both
directions for create / modify / delete / rename.
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 14, 2026

Reviewer's Guide

Refactors websocket heartbeat handling to rely on the websockets library ping/pong, replaces the sleep-based ignore window with a content-hash based echo cache for notes and files, and makes watcher directory moves schedule per-file transitions with proper ignored/excluded handling, backed by new regression tests and protocol documentation.

Sequence diagram for WebSocket heartbeat and dead-connection detection

sequenceDiagram
    participant Client
    participant WebsocketsLib
    participant Server

    Client->>WebsocketsLib: connect(url, ping_interval=45, ping_timeout=90)
    WebsocketsLib->>Server: WebSocket handshake
    Server-->>WebsocketsLib: Authorization response
    WebsocketsLib-->>Client: connection established

    loop Server ping loop (every 25s)
        Server-->>WebsocketsLib: Ping
        WebsocketsLib-->>Server: Pong (auto)
    end

    loop Client ping loop (every 45s)
        WebsocketsLib-->>Server: Ping (auto)
        Server-->>WebsocketsLib: Pong
    end

    alt Server stops responding or path is half-open
        WebsocketsLib-->>Client: ping timeout after 90s
        Client->>WebsocketsLib: close()
    end
Loading

Sequence diagram for note echo-cache on modify/delete and NeedPush

sequenceDiagram
    participant Watcher
    participant NoteSync
    participant Server

    rect rgb(235,235,255)
    Note over Watcher,NoteSync: Outbound modify with echo suppression
        Watcher->>NoteSync: push_modify(rel_path, force=False)
        NoteSync->>NoteSync: read file, compute hash_ = content_hash(text)
        alt hash_ equals NoteSync._echo_hashes[rel_path]
            NoteSync-->>Watcher: return (skip echo)
        else hash_ differs or missing
            NoteSync->>Server: ACTION_NOTE_MODIFY(contentHash=hash_)
            NoteSync->>NoteSync: _echo_hashes[rel_path] = hash_
        end
    end

    rect rgb(235,255,235)
    Note over Server,NoteSync: Inbound modify broadcast from server
        Server-->>NoteSync: NoteSyncModify(path, content)
        NoteSync->>NoteSync: write file to disk
        NoteSync->>NoteSync: _echo_hashes[path] = content_hash(content)
        NoteSync-->>Watcher: filesystem write triggers local modify event
        Watcher->>NoteSync: push_modify(path, force=False)
        NoteSync->>NoteSync: hash_ matches _echo_hashes[path] -> skip
    end

    rect rgb(255,235,235)
    Note over Watcher,NoteSync: Delete and tombstone reuse
        Watcher->>NoteSync: push_delete(path)
        alt NoteSync._echo_hashes[path] == _DELETED
            NoteSync-->>Watcher: return (skip echo)
        else
            NoteSync->>Server: ACTION_NOTE_DELETE
            NoteSync->>NoteSync: _echo_hashes[path] = _DELETED
        end
        Note over Watcher,NoteSync: Later user recreates same path
        Watcher->>NoteSync: push_modify(path, force=False)
        NoteSync->>NoteSync: hash_ != _DELETED -> send modify and update cache
    end

    rect rgb(255,255,210)
    Note over Server,NoteSync: NeedPush forcing a resend
        Server-->>NoteSync: NoteSyncNeedPush(path)
        NoteSync->>NoteSync: log request
        NoteSync->>NoteSync: call push_modify(path, force=True)
        NoteSync->>NoteSync: compute hash_ (no echo check)
        NoteSync->>Server: ACTION_NOTE_MODIFY(contentHash=hash_)
        NoteSync->>NoteSync: _echo_hashes[path] = hash_
    end
Loading

Sequence diagram for file echo-cache with chunked download and rename

sequenceDiagram
    participant Watcher
    participant FileSync
    participant Server

    rect rgb(235,235,255)
    Note over Watcher,FileSync: Outbound file upload with echo suppression
        Watcher->>FileSync: push_upload(path)
        FileSync->>FileSync: hash_ = file_content_hash_binary(full)
        alt hash_ equals FileSync._echo_hashes[path]
            FileSync-->>Watcher: return (skip upload)
        else
            FileSync->>Server: FileUploadCheck(contentHash=hash_)
            FileSync->>FileSync: _echo_hashes[path] = hash_
        end
    end

    rect rgb(235,255,235)
    Note over Server,FileSync: Chunked download finalization
        Server-->>FileSync: binary chunks for session_id
        FileSync->>FileSync: buffer chunks in _download_sessions
        Server-->>FileSync: final chunk
        FileSync->>FileSync: _finalize_download(session_id)
        FileSync->>FileSync: write all chunks to disk
        FileSync->>FileSync: _echo_hashes[path] = file_content_hash_binary(full)
        FileSync-->>Watcher: filesystem write triggers local event
        Watcher->>FileSync: push_upload(path)
        FileSync->>FileSync: hash_ matches cache → skip
    end

    rect rgb(255,235,235)
    Note over Watcher,FileSync: Delete and rename handling in cache
        Watcher->>FileSync: push_delete(old_path)
        alt FileSync._echo_hashes[old_path] == _DELETED
            FileSync-->>Watcher: return (skip delete)
        else
            FileSync->>Server: FileDelete
            FileSync->>FileSync: _echo_hashes[old_path] = _DELETED
        end

        Server-->>FileSync: FileSyncRename(old_path, new_path)
        FileSync->>FileSync: _echo_hashes[old_path] = _DELETED
        FileSync->>FileSync: rename on disk old_full -> new_full
        FileSync->>FileSync: _echo_hashes[new_path] = file_content_hash_binary(new_full)
    end
Loading

Sequence diagram for directory rename handling with excluded paths

sequenceDiagram
    participant Watchdog
    participant Watcher
    participant SyncEngine

    Note over Watchdog,Watcher: Directory rename observed by watchdog
    Watchdog-->>Watcher: on_moved(event is_directory=True)

    Watcher->>Watcher: _handle_directory_move(event)
    Watcher->>Watcher: old_dir_rel = _rel(event.src_path)
    Watcher->>Watcher: new_dir_rel = _rel(event.dest_path)
    Watcher->>Watcher: iterate new_dir.rglob("*")

    loop For each child file under renamed directory
        Watcher->>Watcher: new_rel = _rel(child)
        Watcher->>Watcher: compute tail and old_rel from prefixes
        Watcher->>Watcher: _schedule_move_transition(old_rel, new_rel)

        alt both old_rel and new_rel ignored
            Watcher-->>Watcher: return
        else old_rel or new_rel ignored
            Watcher-->>Watcher: return
        else both excluded
            Watcher-->>Watcher: return
        else moved from included to excluded
            Watcher->>SyncEngine: on_local_delete(old_rel) (scheduled)
        else moved from excluded to included
            Watcher->>SyncEngine: on_local_change(new_rel) (scheduled)
        else normal rename (both included)
            Watcher->>SyncEngine: on_local_rename(new_rel, old_rel) (scheduled)
        end
    end
Loading

Updated class diagram for sync client, echo cache, and directory watcher

classDiagram

    class Client {
        -config: AppConfig
        -ws: WebSocket
        -_on_reconnect: Callable
        -_msg_queue: list
        -_ready_event: asyncio.Event
        +on_reconnect(handler: Callable) void
        +connect() Coroutine
        -_connect() Coroutine
        -_listen() Coroutine
        -_handle_text(raw: str) Coroutine
        -_handle_binary(raw: bytes) Coroutine
    }

    class NoteSync {
        -engine: SyncEngine
        -config: AppConfig
        -vault_path: Path
        -_received_modify: int
        -_received_delete: int
        -_got_end: bool
        -_echo_hashes: dict~str, str~
        +is_sync_complete: bool
        +request_full_sync() Coroutine
        +push_modify(rel_path: str, force: bool=False) Coroutine
        +push_delete(rel_path: str) Coroutine
        +push_rename(new_rel: str, old_rel: str) Coroutine
        -_on_sync_modify(msg: WSMessage) Coroutine
        -_on_sync_delete(msg: WSMessage) Coroutine
        -_on_sync_need_push(msg: WSMessage) Coroutine
        -_on_sync_end(msg: WSMessage) Coroutine
    }

    class FileSync {
        -engine: SyncEngine
        -config: AppConfig
        -vault_path: Path
        -_received_modify: int
        -_received_delete: int
        -_got_end: bool
        -_echo_hashes: dict~str, str~
        -_download_sessions: dict
        +is_sync_complete: bool
        +push_upload(rel_path: str) Coroutine
        +push_delete(rel_path: str) Coroutine
        -_on_sync_update(msg: WSMessage) Coroutine
        -_on_sync_delete(msg: WSMessage) Coroutine
        -_on_sync_rename(msg: WSMessage) Coroutine
        -_on_binary_chunk(session_id: str, chunk_index: int, data: bytes) Coroutine
        -_finalize_download(session_id: str, session: _DownloadSession) Coroutine
        -_on_sync_end(msg: WSMessage) Coroutine
    }

    class Watcher {
        -engine: SyncEngine
        -_schedule(label: str, fn: Callable) void
        -_rel(path: str) str
        +on_modified(event: FileSystemEvent) void
        +on_deleted(event: FileSystemEvent) void
        +on_moved(event: FileSystemEvent) void
        -_handle_directory_move(event: FileSystemEvent) void
        -_schedule_move_transition(old_rel: str, new_rel: str) void
    }

    class SyncEngine {
        +ws_client: Client
        +note_sync: NoteSync
        +file_sync: FileSync
        +is_ignored(rel_path: str) bool
        +is_excluded(rel_path: str) bool
        +on_local_change(rel_path: str) Coroutine
        +on_local_delete(rel_path: str) Coroutine
        +on_local_rename(new_rel: str, old_rel: str) Coroutine
    }

    Client --> NoteSync : used via ws_client
    Client --> FileSync : used via ws_client
    SyncEngine o-- Client
    SyncEngine o-- NoteSync
    SyncEngine o-- FileSync
    Watcher --> SyncEngine
Loading

File-Level Changes

Change Details Files
Add per-path content-hash echo caches for notes and files to suppress true echo events while still propagating reverts and delete/recreate flows, and remove the sleep-based ignore_file window from sync handlers.
  • Introduce _echo_hashes dict and _DELETED sentinel in NoteSync and FileSync to track last synced state per path.
  • Update push_modify/push_upload to compute content hashes, skip sends when current hash matches cache, and record outbound hashes so local edits that restore previous content still push.
  • Update push_delete to consult and set _DELETED in the cache, avoiding duplicate delete pushes while allowing delete-after-recreate to propagate.
  • In inbound modify/update/delete/rename/download-finalize handlers, update _echo_hashes only after successful writes so failed inbound operations do not poison the cache.
  • Change NoteSync._on_sync_need_push to call push_modify(force=True) to bypass echo suppression for explicit server NeedPush requests.
  • Remove engine.ignore_file/unignore_file and async sleep delays from note/file inbound handlers and download finalize to keep the receive loop non-blocking.
fns_cli/note_sync.py
fns_cli/file_sync.py
tests/test_echo_cache.py
Adjust websocket heartbeat strategy to use the websockets library ping interval/timeout instead of a custom inactivity watchdog, preventing false disconnects during heavy chunk transfers.
  • Configure websockets.connect with ping_interval=45 and ping_timeout=90 instead of disabling pings, relying on the library to auto-respond to server pings and detect half-open connections.
  • Remove the client-side inactivity watchdog task, last-received timestamp tracking, and related updates from text/binary handlers to avoid application-level idle disconnects.
  • Document the heartbeat behavior and rationale in the new server protocol reference.
fns_cli/client.py
doc/server-protocol.md
Improve filesystem watcher move handling to correctly process directory renames and moves into/out of excluded paths, ensuring the server sees the correct per-file transitions.
  • Change on_moved to delegate directory moves to a new _handle_directory_move helper that walks the renamed tree, computes old/new relative paths by prefix swapping, and schedules transitions per file.
  • Factor move scheduling into _schedule_move_transition, which respects is_ignored for both old and new paths and handles excluded-path edge cases by emitting delete or modify events instead of renames.
  • Clarify on_deleted behavior for directory deletes via comments, noting platform differences and limitations.
  • Extend watcher tests to cover moves into excluded paths and directory moves into excluded trees producing per-child delete transitions.
fns_cli/watcher.py
tests/test_file_sync.py
Add protocol-level documentation summarizing the server WebSocket behavior and client pitfalls that motivated these fixes.
  • Create doc/server-protocol.md documenting connection/authentication, heartbeat, broadcast mechanics, sync flow, binary chunking, and prior client pitfalls.
  • Note that client.heartbeat_interval config is now unused but retained for backward compatibility.
doc/server-protocol.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Go1c Go1c merged commit 2db6720 into main Apr 14, 2026
4 checks passed
@Go1c Go1c deleted the fix/websocket-echo-cache branch April 14, 2026 13:41
Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In FileSync._on_sync_delete the _echo_hashes[rel_path] = _DELETED assignment happens before the filesystem delete; this differs from NoteSync._on_sync_delete and can poison the cache if the unlink fails—consider moving the assignment into the successful path of the try block to keep the semantics consistent with notes and the regression tests’ intent.
  • The test_failed_inbound_delete_does_not_poison_cache test in test_echo_cache.py compares against the literal string "_DELETED" rather than the actual _DELETED sentinel value, so it won’t fail if the cache is mistakenly set to "__deleted__"; importing and asserting against the real sentinel (or checking that the path is absent from _echo_hashes) would make this regression test robust.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `FileSync._on_sync_delete` the `_echo_hashes[rel_path] = _DELETED` assignment happens before the filesystem delete; this differs from `NoteSync._on_sync_delete` and can poison the cache if the unlink fails—consider moving the assignment into the successful path of the `try` block to keep the semantics consistent with notes and the regression tests’ intent.
- The `test_failed_inbound_delete_does_not_poison_cache` test in `test_echo_cache.py` compares against the literal string `"_DELETED"` rather than the actual `_DELETED` sentinel value, so it won’t fail if the cache is mistakenly set to `"__deleted__"`; importing and asserting against the real sentinel (or checking that the path is absent from `_echo_hashes`) would make this regression test robust.

## Individual Comments

### Comment 1
<location path="fns_cli/note_sync.py" line_range="112-115" />
<code_context>
         await self.engine.ws_client.send(msg)

-    async def push_modify(self, rel_path: str) -> None:
+    async def push_modify(self, rel_path: str, *, force: bool = False) -> None:
         full = self.vault_path / rel_path
         if not full.exists():
</code_context>
<issue_to_address>
**issue (bug_risk):** Echo suppression in `push_modify` can suppress the "new" side of a rename if the target path previously existed with identical content.

Since `push_rename` calls `push_modify(new_rel)` then `push_delete(old_rel)`, a rename into a path already present in `_echo_hashes` with the same hash (e.g. reusing an old filename with identical contents) will skip the modify and only send a delete for `old_rel`. The server never learns to create `new_rel`. Consider skipping echo suppression for the new side of a rename (similar to `force=True` handling), or adding an explicit rename operation to preserve correct semantics.
</issue_to_address>

### Comment 2
<location path="tests/test_echo_cache.py" line_range="174-183" />
<code_context>
+            f"expected local push after failed inbound write, got {actions}",
+        )
+
+    async def test_failed_inbound_delete_does_not_poison_cache(self):
+        """A failed server delete must not suppress a later real local delete."""
+        rel = "note.md"
+        target = self.vault / rel
+        target.write_text("A", encoding="utf-8")
+
+        with patch.object(Path, "unlink", side_effect=OSError("boom")):
+            await self._inbound_delete(rel)
+
+        self.assertNotEqual(self.ns._echo_hashes.get(rel), "_DELETED")
+
+        target.unlink()
</code_context>
<issue_to_address>
**suggestion (testing):** Avoid string-literal comparison to the deletion sentinel; import and assert against the actual _DELETED constant instead.

This test currently checks `self.ns._echo_hashes.get(rel) != "_DELETED"` while the implementation uses a `_DELETED` constant. Comparing to a string literal makes the test brittle if the sentinel changes. Please import `_DELETED` from `fns_cli.note_sync` and assert against that constant (or assert that the key is absent / not equal to the sentinel object). Apply the same approach to any similar checks for the file sync echo cache.

Suggested implementation:

```python
        self.assertNotEqual(self.ns._echo_hashes.get(rel), _DELETED)

```

1. At the top of `tests/test_echo_cache.py`, add an import for the sentinel constant:
   - `from fns_cli.note_sync import _DELETED`
   Place this alongside the other `fns_cli` imports to match existing style.
2. Search the rest of `tests/test_echo_cache.py` for any other string-literal comparisons to the deletion sentinel (e.g. `"_DELETED"`), and update them to use the imported `_DELETED` constant in the same way:
   - Replace `"_DELETED"` with `_DELETED` in those assertions or checks.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread fns_cli/note_sync.py
Comment on lines +112 to 115
async def push_modify(self, rel_path: str, *, force: bool = False) -> None:
full = self.vault_path / rel_path
if not full.exists():
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Echo suppression in push_modify can suppress the "new" side of a rename if the target path previously existed with identical content.

Since push_rename calls push_modify(new_rel) then push_delete(old_rel), a rename into a path already present in _echo_hashes with the same hash (e.g. reusing an old filename with identical contents) will skip the modify and only send a delete for old_rel. The server never learns to create new_rel. Consider skipping echo suppression for the new side of a rename (similar to force=True handling), or adding an explicit rename operation to preserve correct semantics.

Comment thread tests/test_echo_cache.py
Comment on lines +174 to +183
async def test_failed_inbound_delete_does_not_poison_cache(self):
"""A failed server delete must not suppress a later real local delete."""
rel = "note.md"
target = self.vault / rel
target.write_text("A", encoding="utf-8")

with patch.object(Path, "unlink", side_effect=OSError("boom")):
await self._inbound_delete(rel)

self.assertNotEqual(self.ns._echo_hashes.get(rel), "_DELETED")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Avoid string-literal comparison to the deletion sentinel; import and assert against the actual _DELETED constant instead.

This test currently checks self.ns._echo_hashes.get(rel) != "_DELETED" while the implementation uses a _DELETED constant. Comparing to a string literal makes the test brittle if the sentinel changes. Please import _DELETED from fns_cli.note_sync and assert against that constant (or assert that the key is absent / not equal to the sentinel object). Apply the same approach to any similar checks for the file sync echo cache.

Suggested implementation:

        self.assertNotEqual(self.ns._echo_hashes.get(rel), _DELETED)
  1. At the top of tests/test_echo_cache.py, add an import for the sentinel constant:
    • from fns_cli.note_sync import _DELETED
      Place this alongside the other fns_cli imports to match existing style.
  2. Search the rest of tests/test_echo_cache.py for any other string-literal comparisons to the deletion sentinel (e.g. "_DELETED"), and update them to use the imported _DELETED constant in the same way:
    • Replace "_DELETED" with _DELETED in those assertions or checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant