Skip to content

Possible SAF cache bugs affecting hashing, deletion and directory lookups #263

Closed

Description

@SoongVilda

Hi, I asked AI to review BasicSync and run focused tests against commit 05062ab - I sometimes notices BasiSync eats more battery on S25U than before, so I tried a dig a bit deeper. Three things came up around the SAF cache. As I understand it, these could be addressed within BasicSync.

  1. Cached metadata during hashing

    safFile.Stat() uses cached metadata. In the test, a file grew from 6 to 15 bytes, but the actual scanner successfully hashed only the original 6 bytes. Another test changed the file during hashing, and the final check still accepted it.

    Expiring the metadata cache made both checks behave correctly. Could opened-file metadata be refreshed for these checks?

  2. Removing a directory based on cached emptiness

    Remove() can use a cached child listing.

    The test cached an empty directory, then added a file externally without a watcher notification. The walker missed that file, and removal called the mock provider’s recursive deletion, which deleted it. Refreshing the cache instead returned ENOTEMPTY and preserved the file.

    Could directory removal require a fresh, complete listing?

  3. Directory-map access and allocations

    getChildren() returns a copy on cache hits, but returns the internal map after refreshing. Go’s race detector reported a race when that returned map was read during a locked removal.

    Also, individual Resolve() lookups copy the whole directory map. Enumerating and resolving 1,000 entries allocated about 54.8 MB; 10,000 entries allocated about 4.37 GB in total, not peak memory.

    Would a locked single-child lookup and consistent snapshots address this?

These were Linux tests using Go 1.26.2, unchanged production code and a mocked Android provider. The existing SAF tests and the added control cases passed. Android behavior, complete peer synchronization and battery impact haven’t been verified.

Could you take a look whether these are valid bugs in the bridge?

Activity

  1. self-assigned this
    on Sep 30, 2026
  2. chenxiaolong commented on Sep 30, 2026

    @chenxiaolong
    Owner

    safFile.Stat() uses cached metadata. In the test, a file grew from 6 to 15 bytes, but the actual scanner successfully hashed only the original 6 bytes. Another test changed the file during hashing, and the final check still accepted it.

    The test cached an empty directory, then added a file externally without a watcher notification. The walker missed that file, and removal called the mock provider’s recursive deletion, which deleted it. Refreshing the cache instead returned ENOTEMPTY and preserved the file.

    It is intentional that all cached metadata only expire either due to TTL or watcher notifications.

    The SAF feature was added with AOSP's FileSystemProvider as the main use case, which sends watcher notifications on file close (for the first case) and directory mutation (for the second case). Other third party SAF providers without watcher support will see filesystem state that doesn't match reality until TTL expiration. I don't really intend to support third party SAF providers, though the app currently doesn't prevent them from being used.

    getChildren() returns a copy on cache hits, but returns the internal map after refreshing. Go’s race detector reported a race when that returned map was read during a locked removal.

    That's definitely a bug! It's supposed to always return a clone. I'll fix that.

    Also, individual Resolve() lookups copy the whole directory map. Enumerating and resolving 1,000 entries allocated about 54.8 MB; 10,000 entries allocated about 4.37 GB in total, not peak memory.

    Would a locked single-child lookup and consistent snapshots address this?

    I might introduce a getChild(childName) function to improve this so that copying the children map isn't necessary for path resolution. However, I don't plan on changing the caching or locking logic. SAF is painful enough that my main priority is just to keep it simple.

  3. chenxiaolong commented on Sep 30, 2026

    @chenxiaolong
    Owner

    The getChildren() issue has been fixed in #264 and reducing children map copying in Resolve() is done in #265.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions