Skip to content

fix: delay watcher unignore to prevent echo push-back loop#7

Merged
Go1c merged 1 commit intomainfrom
fix/echo-loop-watcher-race
Apr 14, 2026
Merged

fix: delay watcher unignore to prevent echo push-back loop#7
Go1c merged 1 commit intomainfrom
fix/echo-loop-watcher-race

Conversation

@Go1c
Copy link
Copy Markdown
Owner

@Go1c Go1c commented Apr 14, 2026

Problem

When the CLI receives a NoteSyncModify / FileSyncUpdate / FileSyncDelete from the server and applies it locally, the watchdog's inotify event is delivered asynchronously by the kernel. By the time the observer thread processes the event, unignore_file had already been called — causing the watcher to treat the server-written file as a local change and push it straight back to the server.

Confirmed in logs:

08:32:11  ← NoteSyncModify: Test-456/未命名.md   (server → CLI)
08:32:12  NoteModify → Test-456/未命名.md          (CLI echoes back)
08:44:53  ← NoteSyncModify: Test-456/未命名.md   (server pushes again)
08:44:53  NoteModify → Test-456/未命名.md          (CLI echoes again — loop)

Fix

Add await asyncio.sleep(0.6) before unignore_file in all server→client file operation handlers. This holds the ignore lock past the watchdog debounce window (0.5 s), so any inotify events from the server-side write/delete/rename are suppressed before the lock is released.

Affected handlers (5 total):

  • NoteSync._on_sync_modify
  • NoteSync._on_sync_delete
  • FileSync._on_sync_update
  • FileSync._on_sync_delete
  • FileSync._on_sync_rename
  • FileSync._finalize_download

Summary by Sourcery

Delay unignoring files after applying server-initiated sync operations to avoid echoing those changes back to the server.

Bug Fixes:

  • Prevent server-originated file and note updates, deletes, renames, and downloads from being re-detected as local changes by the filesystem watcher and re-sent to the server.

Enhancements:

  • Align all server-to-client sync handlers to consistently hold the watcher ignore lock slightly beyond the filesystem debounce window.

When the CLI receives a sync event from the server and writes/deletes
a file locally, the watchdog inotify event is queued by the kernel and
delivered asynchronously. By the time the observer thread processes it,
unignore_file had already been called, causing the watcher to treat the
server-written file as a local change and push it back — creating an
echo loop confirmed in logs (NoteSyncModify received → NoteModify sent
back → server pushes again).

Fix: add asyncio.sleep(0.6) before unignore_file in all server→client
file operation handlers (write, delete, rename, chunked download) in
both NoteSync and FileSync. This keeps the file ignored until the
watchdog debounce window (0.5s) has expired.
@Go1c Go1c merged commit cc755f9 into main Apr 14, 2026
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 14, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Adds a small async delay before releasing filesystem ignore locks for server-initiated sync operations to avoid the watchdog treating server writes/deletes/renames as local changes and echoing them back to the server.

Sequence diagram for server-initiated file change handling with delayed unignore

sequenceDiagram
    actor User
    participant WSServer
    participant CLISync
    participant IgnoreManager
    participant Watchdog

    rect rgb(245,245,245)
        User->>WSServer: Edit note/file
        WSServer->>CLISync: NoteSyncModify / FileSyncUpdate
    end

    rect rgb(230,255,230)
        Note over CLISync: After fix (with delay)
        CLISync->>IgnoreManager: ignore_file(rel_path)
        CLISync->>CLISync: Apply server change
        CLISync-->>Watchdog: Filesystem write/delete/rename
        Watchdog-->>IgnoreManager: Check ignore status
        IgnoreManager-->>Watchdog: Path ignored
        Watchdog--xCLISync: No local event dispatched
        CLISync->>CLISync: await asyncio.sleep(0.6)
        CLISync->>IgnoreManager: unignore_file(rel_path)
    end

    rect rgb(255,230,230)
        Note over CLISync: Before fix (no delay)
        CLISync->>IgnoreManager: ignore_file(rel_path)
        CLISync->>CLISync: Apply server change
        CLISync->>IgnoreManager: unignore_file(rel_path)
        CLISync-->>Watchdog: Filesystem write/delete/rename
        Watchdog-->>IgnoreManager: Check ignore status
        IgnoreManager-->>Watchdog: Path not ignored
        Watchdog-->>CLISync: Local modify/delete event
        CLISync->>WSServer: NoteModify / FileSyncUpdate (echo)
        WSServer->>CLISync: Re-sends change (loop)
    end
Loading

File-Level Changes

Change Details Files
Delay unignore for server-initiated file sync operations to outlast the filesystem watcher debounce window.
  • In file update handler, add an await asyncio.sleep(0.6) in the finally block before calling engine.unignore_file(rel_path) so server-triggered writes do not get re-sent.
  • In file delete handler, add the same 0.6s async sleep before engine.unignore_file(rel_path) to suppress echo deletes.
  • In file rename handler, add a 0.6s async sleep before unignoring both the old and new paths, ensuring rename inotify events are ignored by the watcher.
  • In download finalization, move engine.unignore_file(rel_path) to after a 0.6s async sleep and after removing the download session, so watcher events from the completed download are ignored.
fns_cli/file_sync.py
Delay unignore for server-initiated note sync operations so note file writes/deletes from the server are not echoed back.
  • In note modify handler, add an await asyncio.sleep(0.6) in the finally block before calling engine.unignore_file(rel_path) to keep the ignore lock during inotify delivery.
  • In note delete handler, add the same 0.6s sleep before engine.unignore_file(rel_path) to avoid pushing server-initiated deletes back to the server.
fns_cli/note_sync.py

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 deleted the fix/echo-loop-watcher-race branch April 14, 2026 09:02
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 left some high level feedback:

  • The hard-coded await asyncio.sleep(0.6) is a bit of a magic number; consider centralizing this debounce duration in a clearly named constant or configuration so its intent is clear and easier to tune across all handlers.
  • Since the sleep is happening inside finally, these handlers will now always incur at least 0.6s of extra runtime even on errors; consider whether you want to skip or shorten the delay on failure cases, or make the delay conditional on successful local application.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The hard-coded `await asyncio.sleep(0.6)` is a bit of a magic number; consider centralizing this debounce duration in a clearly named constant or configuration so its intent is clear and easier to tune across all handlers.
- Since the sleep is happening inside `finally`, these handlers will now always incur at least 0.6s of extra runtime even on errors; consider whether you want to skip or shorten the delay on failure cases, or make the delay conditional on successful local application.

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.

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