Skip to content

feat: load remote ignore patterns (fixes #9352) - #10669

Draft
calmh wants to merge 5 commits into
syncthing:mainfrom
calmh:jb/remotefs
Draft

calmh wants to merge 5 commits into
syncthing:mainfrom
calmh:jb/remotefs

Conversation

@calmh

@calmh calmh commented Apr 25, 2026 •

Copy link
Copy Markdown
Member

This adds support for loading ignore files remotely on initial folder setup. That is, it's fine to create a folder with an .stignore that says #include some-other-file, as long as some-other-file is available from a peer device.

This is accomplished by adding a new filesystem shim RemoteFilesystem which serves Stat()/Lstat() by returning the global index entry from the database, and serves Open() calls by reading the file from a remote peer and buffering it in-memory. Obviously this is inefficient for general usage, but it's perfectly fine for ignore files.

The ignore loader then gets a layered view of the local filesystem + a remote filesystem that serves files that don't exist locally, via the new LayeredFilesystem abstraction.

There's a fair amount of new code in the fs package, but it's quite conceptually simple, a lot of it just being wrapping and not-implemented stuff.

The folder, on initial setup, will flip to errored when the ignore file is unavailable both locally and remotely (because we haven't received the index yet). Then on a retry, it'll load the ignores and continue. This is a wrinkle, but I think it's fine for now...

@github-actions github-actions Bot added the enhancement New features or improvements of some kind, as opposed to a problem (bug) label Apr 25, 2026
@calmh calmh changed the title feat: load remote ignore patterns feat: load remote ignore patterns (fixes #9352) Apr 25, 2026
@tomasz1986

tomasz1986 commented Apr 25, 2026 •

Copy link
Copy Markdown
Member

Just a quick question, but what is going to happen when the #include file is located outside of the Syncthing folder (i.e. using .. and relative paths)? I don't do that but I've seen some users do. Is the folder just going to end up getting stopped (as is the case right now)?

@calmh

calmh commented Apr 25, 2026

Copy link
Copy Markdown
Member Author

Yup that gets rejected when it tries to load it and the folder remains stopped.

This adds support for loading ignore files remotely on initial folder
setup. That is, it's fine to create a folder with an .stignore that says
`#include some-other-file`, as long as some-other-file is available from
a peer device.

This is accomplished by adding a new filesystem shim `RemoteFilesystem`
which serves Stat()/Lstat() by returning the global index entry from the
database, and serves Open() calls by reading the file from a remote
peer and buffering it in-memory. Obviously this is inefficient for
general usage, but it's perfectly fine for ignore files.

The ignore loader then gets a layered view of the local filesystem + a
remote filesystem that serves files that don't exist locally, via the
new `LayeredFilesystem` abstraction.

Signed-off-by: Jakob Borg <jakob@kastelo.net>
calmh added 2 commits April 26, 2026 15:17
Signed-off-by: Jakob Borg <jakob@kastelo.net>
Signed-off-by: Jakob Borg <jakob@kastelo.net>

@acolomb acolomb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Beautiful architecture! I wonder how much overlap there is with the remote streaming access implemented by @pixelspark? Maybe the two approaches can converge in the long term?

Some dumb questions and ideas came to mind during review, ignore at will.

Comment thread lib/fs/layeredfs.go Outdated
// can supply paths missing from higher ones; any other error is surfaced
// immediately without consulting further layers.
//
// Walks, globs and directory listings are not merged across layers — the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not quite what the code does currently. Walk targets only the top layer.

Comment thread lib/fs/layeredfs.go
Comment on lines +176 to +182
func (f *layeredFilesystem) Watch(path string, ignore Matcher, ctx context.Context, ignorePerms bool) (<-chan Event, <-chan error, error) {
return f.top().Watch(path, ignore, ctx, ignorePerms)
}

func (f *layeredFilesystem) SameFile(a, b FileInfo) bool {
return f.top().SameFile(a, b)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see how these don't fit the common signature assumed in readCascade(), but at least for SameFile, wouldn't it make sense to not only consider the top layer? Feels like this touches an invariant that may cause subtle bugs later on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We'd have to change the signature to enable that, as the current return doesn't expose the reason for a false return. Additionally, this is only ever used as a check prior to doing a write operation, which can only affect the top layer...

Comment thread lib/fs/remotefs.go

func (f *remoteFilesystem) URI() string { return "remote://" + f.folderId }

func (f *remoteFilesystem) Options() []Option { return nil }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could return the openTimeout as an option here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kinda, but not currently as it's not taken as an Option as that isn't how the remotefs is instantiated. Some liberties taken here since this is not an fs that would/could ever back an actual folder in its entirety.

Comment thread lib/fs/remotefs.go
}

func (*remoteFilesystem) GetXattr(_ string, _ XattrFilter) ([]protocol.Xattr, error) {
return nil, errRemoteNotImpl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We do store the PlatformData in the DB, is there a good reason to not even try returning it here? Except being additional work for no immediate benefit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, that, effectively. We don't need it, so it would just be unused.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Then I guess a comment to that effect would help anybody who might later need it and wonders why it doesn't work.

Comment thread lib/fs/remotefs.go
return nil, nil, ErrWatchNotSupported
}

func (*remoteFilesystem) SameFile(_, _ FileInfo) bool { return false }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This could be implemented with little effort by exposing a primary key (sequence) comparison on the folder DB. Or even simpler, just a string comparison on the name, since the file_names has a name TEXT NOT NULL UNIQUE COLLATE BINARY guarantee.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As above, this is only ever used for handling utf-8 normalisation renames.

Comment thread lib/fs/remotefs.go

func (fi *remoteFileInfo) IsRegular() bool { return fi.info.Type == protocol.FileInfoTypeFile }

func (fi *remoteFileInfo) IsSymlink() bool { return fi.info.IsSymlink() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't this contradict SymlinksSupported above always returning false? Or what exactly is the semantic expectation implied by SymlinksSupported?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Possibly... in fact, looking at it, I don't see anywhere we actually call SymlinksSupported() ever, so that may be a historical artifact to remove. Will look into that separately...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes SymlinksSupported() is never called.

@pixelspark

pixelspark commented Apr 27, 2026 •

Copy link
Copy Markdown
Contributor

@acolomb thanks for the tag!

First of all, this PR looks good and should scratch an itch for a few power users. I envision the next step could be to allow provisioning a 'global' .stignore (i.e. have a configuration setting that tells Syncthing that instead of looking at a local .stignore, it should assume the contents had been #include .stglobalignore, and that file could then exist in the global tree).

On the remote FS part: Synctrain indeed uses the same RequestGlobal / GetGlobalAvailability primitives used here (albeit through the Internals interface) to fetch (parts of) files on demand using BEP. If the 'remote FS' interface ever becomes (informal, unstable) API, it could be used by Synctrain. In the current form however, the existing primitives are much more useful, as they allow Synctrain to implement various improvements specific to its use cases.

A primary use case in Synctrain is on-demand video streaming from an (incompletely synced, because .stignore contains *) Syncthing folder on an iPhone. The downstream interface for the stream is an HTTP server serving range requests. The native iOS video player will receive a localhost URL and make periodic range requests to it. These byte ranges are translated to ranges of blocks, which are then either loaded from the local copy of the file, an in-memory LRU block cache, or requested using RequestGlobal.

For the remote fetch, we have to deal with the fact that mobile connectivity can be highly dynamic. If you happen to be on a Wi-Fi LAN with a peer that has your video, you want the app to download from that peer instead of through a 3G connection to a peer far away. If a peer suddenly becomes slow or unreachable while streaming, the app should switch over to another one. Also latency tends to be higher on mobile, so you want to make an educated guess about which peer can serve you best, or you'll have to wait needlessly long before the video starts.

The current implementation (which I call the 'mini puller') basically first sorts peers by their measured latency (measured periodically by the app by making requests to invalid blocks to each peer, and measuring the time it takes for the error to come back :-)). For each download, it keeps track of its 'experience' with downloading blocks from each peer (which can be either success, fail or unknown). It will try to keep downloading from peers that were succesful before, if that fails it will try an unknown one, and finally it will retry the bad peers (before the cycle restarts). Then there is some timeout/retry logic. (I am considering adding e.g. 'race' logic, where the app attempts to download a block from multiple peers, and then goes with whatever peer is the first to deliver a block. Still have to experiment a bit).

(Edit: as you can see there is also some logic for pipelining block download. This is used when the user wants to download a file on-demand in full.)

If such logic is useful to have in Synctrain core, feel free to take some inspiration.

calmh added 2 commits April 28, 2026 08:52
Signed-off-by: Jakob Borg <jakob@kastelo.net>
* upstream/main:
  chore(gui, man, authors): update docs, translations, and contributors

This branch has not been deployed

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

Labels

enhancement New features or improvements of some kind, as opposed to a problem (bug)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants