Repository: rclone/rclone
Pull request #7154 (2023-07-19 15:24:12)

Add OpenChunkWriter and ChunkWriter interfaces and refactor s3.uploadMultpart and multiThreadCopy to use them
#### What is the purpose of this change?

Following the discussions in https://github.com/rclone/rclone/pull/7061 and https://github.com/rclone/rclone/issues/7056 this change is meant to pave the way for fast parallel transfers between remotes.
It defines the interfaces suggested by @jorjao81 and implements them for the S3 and Local backend. 
Also, the s3.uploadMultipart logic was refactored to use OpenChunkWriter and ChunkWriter (s3 multi part uploads should behave the same as before).

In [https://github.com/rclone/rclone/pull/7061](https://github.com/rclone/rclone/pull/7061#issuecomment-1594949243) - @ncw suggested the following roadmap:
 - [x] Define new interfaces in fs
 - [x] Implement OpenChunkWriter and ChunkWriter for local backend.
 - [x] Add tests of OpenChunkWriter and ChunkWriter to the integration tests fstest/fstests/fstests.go
 - [x] Refactor multipart upload in s3 backend to create OpenChunkWriter and ChunkWriter and Leave multipart upload in s3 backend for the moment just using the new interface.
 
Therefore, the next steps would be:
 - [x] Rework multi-thread copy to use new interface
 - [ ] Refactor multipart upload in s3 to use version in operations

Looking forward to some feedback. I'm not experienced with Go, so apologies for any (dumb) mistake. 😅
 
#### Was the change discussed in an issue or in the forum before?

https://github.com/rclone/rclone/pull/7061
https://github.com/rclone/rclone/issues/7056

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [x] I have added tests for all changes in this PR if appropriate.
- [x] I have added documentation for the changes if appropriate.
- [x] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)


jorjao81: @ncw FYI: @vitorog is my work colleague, we are working together to get this done. You might also see commits from @AffDNeto and @sysedwinistrator :)

Tomorrow we will try to do the same thing @vitorog did here for S3 on another provider, probably GCS, to further validate the interface.
jorjao81: After looking at the code a bit, I saw that the way gcp does multipart is basically by implement the S3 API, and this is already supported by configuring a GCS bucket to use S3, so I'll leave that out for now
ncw: If we can work out whether we need an `io.Reader` or a `[]byte` that would be very useful. Rclone has a useful little adapter   [readers.Repeatable](https://github.com/rclone/rclone/blob/master/lib/readers/repeatable.go) which can turn an io.Reader into an io.ReadSeeker but only using the buffer memory if needed.

I've just done the 1.63 release so this is a great time in the dev cycle for experimental code!
ncw: > Therefore, the next steps would be:
> 
> * [ ]  Rework multi-thread copy to use new interface

Are we going to remove support for `OpenWriterAt` in the multi-thread copy code?

Just thinking about how this would work for the local backend. It would use more memory with more buffers, but it would probably leave the files less fragmented as it would be writing small chunks but all close to each other which the OS is likely to buffer in RAM. So as far as local backend is concerned this is probably an improvement.

So we should ditch the `OpenWriterAt` code and convert it completely to `ChunkWriter`.

I note that the conversion from OpenWriterAt to ChunkWriter is generic - I say this because there is a PR in the pipeline adding OpenWriterAt to the smb backend so it would be nice not to re-write it there! So you could make the code in local just instantiate an adaptor to implement the interface.

> * [ ]  Refactor multipart upload in s3 to use version in operations

Multithread copies can't do unknown sized copies at the moment so this needs a little care. I guess what we are factoring out is not the entire multithread copying routine but just the guts of the moving chunks around.

To think about - memory management mmap, memory pools etc. We currently manage this for the s3 etc backends and it is quite important, so we need the same controls in the operations multithread routines.

Though we could turn `[]byte` into io.Reader and punt that to the backends. Having a unified memory pool would be a good idea though. It needs to be a bit more sophisticated than it is at the moment to have chunks of different sizes but that is on the cards.

Note also we have two uses of multithread copy in the s3 backend, one for multipart uploads and the other for server side copies of large files. They use pretty much the same code structure but don't need to move actual bytes. I don't know if this can be factored out - probably not.
jorjao81: > If we can work out whether we need an `io.Reader` or a `[]byte` that would be very useful. Rclone has a useful little adapter [readers.Repeatable](https://github.com/rclone/rclone/blob/master/lib/readers/repeatable.go) which can turn an io.Reader into an io.ReadSeeker but only using the buffer memory if needed.
> 
> I've just done the 1.63 release so this is a great time in the dev cycle for experimental code!

IMHO reader is a better choice:

1. I think it's the "natural" type that we need here, i.e, semantically we need a source of data, []byte seems too low level/implementation specific.
2. The S3 SDK takes a Reader (v2) or ReadSeeker (v1), I think Azure Blob also
3. It also keeps the door of not buffering the whole chunk and using trailers for checksum open.
ncw: > > If we can work out whether we need an `io.Reader` or a `[]byte` that would be very useful. Rclone has a useful little adapter [readers.Repeatable](https://github.com/rclone/rclone/blob/master/lib/readers/repeatable.go) which can turn an io.Reader into an io.ReadSeeker but only using the buffer memory if needed.
> > I've just done the 1.63 release so this is a great time in the dev cycle for experimental code!
> 
> IMHO reader is a better choice:
> 
> 1. I think it's the "natural" type that we need here, i.e, semantically we need a source of data, []byte seems too low level/implementation specific.

Yes, noted.

> 2. The S3 SDK takes a Reader (v2) or ReadSeeker (v1), I think Azure Blob also

There are two reasons the SDK needs to seek

1. Calculation of the hash of the chunk which is needed at the **start** of the transaction before sending any data (unless s3 has started supporting HTTP trailers which is not impossible!)
2. Retries

So I suspect `io.ReadSeeker` is a better option than `io.Reader`

If we were to implement this naively by seeking an incoming data stream then (which is fairly easy - rclone has a way of opening objects as `io.ReadSeekers`) then this will read all the data twice over the network to calculate the hash.

So I think this is likely going to need to be a memory backed buffer anyway. However at some point I'd like to switch to more of a scatter gather memory buffer with a lot of 1MB pages say which will would fit well within the `io.ReadSeeker` interface.

> 3. It also keeps the door of not buffering the whole chunk and using trailers for checksum open.

Yes. Though see above re retries!

I had a look to see what the SDKs are expecting

- s3 v1: `io.ReadSeeker` - note we also need to calculate the MD5SUM of the blob *before* we upload
- azureblob: `io.ReadSeekCloser`
- b2: this is up to us (no SDK), but it can get away with an `io.Reader` as hashes are applied at the end. It needs to buffer for retries though.
- box: this is up to us (no SDK). Needs to be able to retry.
- google cloud storage: `io.Reader` - the SDK handles chunking for us
- onedrive: we use `io.ReadSeeker` implemented by rclone's own `readers.NewRepeatableReader`
- there are others but I stopped there!

So I think `io.ReadSeeker` is probably the minimum interface we can get away with. Would you agree?
jorjao81: > > > If we can work out whether we need an `io.Reader` or a `[]byte` that would be very useful. Rclone has a useful little adapter [readers.Repeatable](https://github.com/rclone/rclone/blob/master/lib/readers/repeatable.go) which can turn an io.Reader into an io.ReadSeeker but only using the buffer memory if needed.
> > > I've just done the 1.63 release so this is a great time in the dev cycle for experimental code!
> > 
> > 
> > IMHO reader is a better choice:
> > 
> > 1. I think it's the "natural" type that we need here, i.e, semantically we need a source of data, []byte seems too low level/implementation specific.
> 
> Yes, noted.
> 
> > 2. The S3 SDK takes a Reader (v2) or ReadSeeker (v1), I think Azure Blob also
> 
> There are two reasons the SDK needs to seek
> 
> 1. Calculation of the hash of the chunk which is needed at the **start** of the transaction before sending any data (unless s3 has started supporting HTTP trailers which is not impossible!)
> 2. Retries
> 
> So I suspect `io.ReadSeeker` is a better option than `io.Reader`
> 
> If we were to implement this naively by seeking an incoming data stream then (which is fairly easy - rclone has a way of opening objects as `io.ReadSeekers`) then this will read all the data twice over the network to calculate the hash.
> 
> So I think this is likely going to need to be a memory backed buffer anyway. However at some point I'd like to switch to more of a scatter gather memory buffer with a lot of 1MB pages say which will would fit well within the `io.ReadSeeker` interface.
> 
> > 3. It also keeps the door of not buffering the whole chunk and using trailers for checksum open.
> 
> Yes. Though see above re retries!
> 
> I had a look to see what the SDKs are expecting
> 
> * s3 v1: `io.ReadSeeker` - note we also need to calculate the MD5SUM of the blob _before_ we upload
> * azureblob: `io.ReadSeekCloser`
> * b2: this is up to us (no SDK), but it can get away with an `io.Reader` as hashes are applied at the end. It needs to buffer for retries though.
> * box: this is up to us (no SDK). Needs to be able to retry.
> * google cloud storage: `io.Reader` - the SDK handles chunking for us
> * onedrive: we use `io.ReadSeeker` implemented by rclone's own `readers.NewRepeatableReader`
> * there are others but I stopped there!
> 
> So I think `io.ReadSeeker` is probably the minimum interface we can get away with. Would you agree?

Damn, I keep forgetting about the retries. So I guess you are right, `io.ReadSeeker` is the minimum we can get away with.
vitorog: hi, @ncw @jorjao81 , based on the discussion, I refactored the ChunkWriter interface to use an io.ReadSeeker.

I also updated the multi-thread copy to use the new interfaces, based on @jorjao81 's PR in #7061 

> So we should ditch the OpenWriterAt code and convert it completely to ChunkWriter.
I note that the conversion from OpenWriterAt to ChunkWriter is generic - I say this because there is a PR in the pipeline adding OpenWriterAt to the smb backend so it would be nice not to re-write it there! So you could make the code in local just instantiate an adaptor to implement the interface.

@ncw I implemented an adapter (openChunkWriterFromOpenWriterAt) in multithread.go. From my understanding, with this adapter we don't even need to implement OpenChunkWriter in the local backend, right?

 
ncw: > hi, @ncw @jorjao81 , based on the discussion, I refactored the ChunkWriter interface to use an io.ReadSeeker.
> 

Great.

> I also updated the multi-thread copy to use the new interfaces, based on @jorjao81 's PR in #7061 and fixed the build and lint errors (I think).

I've re-ran the CI so you can check in a moment.
 
> > So we should ditch the OpenWriterAt code and convert it completely to ChunkWriter.
> > I note that the conversion from OpenWriterAt to ChunkWriter is generic - I say this because there is a PR in the pipeline adding OpenWriterAt to the smb backend so it would be nice not to re-write it there! So you could make the code in local just instantiate an adaptor to implement the interface.
> 
> @ncw I implemented an adapter (openChunkWriterFromOpenWriterAt) in multithread.go. From my understanding, with this adapter we don't even need to implement OpenChunkWriter in the local backend, right? Is this approach what you had in mind for the generic conversion from OpenWriterAt to ChunkWriter?

Your adapter looks great. I made a couple of comments above but it is the right approach definitely. And we don't need the `local` implementation of `OpenChunkWriter` at all. For some backends `OpenWriterAt` is easy to write. I suspect we could do it for `sftp` and `hdfs` quite easily.

vitorog: Thanks for the review  and comments @ncw . 

After running some tests, I noticed an issue with the OpenChunkWriter interface:
Shouldn't it also receive the remote as a parameter?
For example:
`type OpenChunkWriter interface {
	OpenChunkWriter(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (chunkSize int64, writer ChunkWriter, err error)
}`

I'm asking this because the multiThreadCopy function is defined as:
`func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object, streams int, tr *accounting.Transfer)`
where the remote refers to the destination file. 

If we use "src.Remote()" in OpenChunkWriter, it will only work if the destination file has the same name (remote) as the source (otherwise it fails with `"multi-thread copy: failed to find object after copy"`).
What do you think?
ncw: > After running some tests, I noticed an issue with the OpenChunkWriter interface: Shouldn't it also receive the remote as a parameter? For example:
> 
> ```
> type OpenChunkWriter interface {
> 	OpenChunkWriter(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (chunkSize int64, writer ChunkWriter, err error)
> }
> ```

Traditionally you'd use `fs.NewOverrideRemote` to override the Remote in the `src`.

However making it line up with the multi thread copy code makes it slightly easier to use, so adding a `remote` parameter seems like a good idea probably.


msays2000: keep up the good work guys, looking forward to these chunked download, upload interfaces. 

please also consider the interface to list and use meta information of already uploaded parts. This can be used to resume multipart upload after rclone is restarted for whatever reason and its smart to skip uploading parts already upload. Example: https://github.com/rclone/rclone/pull/7189


ncw: > Executed some tests in a **m6in.8xlarge** EC2 instance (network bandwidth of 50 Gbps). Transfer of a 100 GB file from S3 to S3. Each command was executed 3 times.

Amazing performance :-) Is 2.5 GiB/s network saturated? As we are doing down and up or would 5GiB/s be network saturated?

> hi, @ncw, after running more tests, I noticed an issue related to this setModTime call: https://github.com/rclone/rclone/blob/master/fs/operations/multithread.go#L235
> 
> For S3, the setModTime implementation (https://github.com/rclone/rclone/blob/master/backend/s3/s3.go#L5130) makes a copy of the object over itself. However, after doing a multi-threaded copy we don't need to do this, since the metadata should already be correct.

Yes, this SetModTime is left over from copying to the local file system where it is necessary.

> The workaround for this was this commit: [c2d16a6](https://github.com/rclone/rclone/commit/c2d16a60d1db172075453524d664a167d4f7c25f) But I'm not sure if this could cause any problems or if there is a better approach.

I think avoiding the SetModTime at the end if the Fs we are copying to does not have the `f.Features().Partial` feature flag set is probably the right thing to do. Fs without `Partial` only have the complete file at the end so must get the correct modtime at that point too. Sound OK?

I think your patch is probably OK too though!

> Another issue is related to the Accounting. I couldn't find a better way of integrating it with the OpenChunkWriter. Currently, `acc.AccountRead` is called when a chunk finishes - which causes a "delay" in the reporting.

What we want is something like this

https://github.com/rclone/rclone/blob/4444037f5cf67b437ca242f5801adc0be249a13f/fs/accounting/accounting.go#L564

That wraps an `io.ReadSeeker` instead.

I'd be happy to leave a FIXME in the code and address this later if you want.

vitorog: >
> Amazing performance :-) Is 2.5 GiB/s network saturated? As we are doing down and up or would 5GiB/s be network saturated?
>

I think 50 Gbps is the aggregate bandwidth: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-network-bandwidth.html 
That would explain the speed, but honestly I'm not 100% sure... 😅
 
>
> I think avoiding the SetModTime at the end if the Fs we are copying to does not have the `f.Features().Partial` feature flag set is probably the right thing to do. Fs without `Partial` only have the complete file at the end so must get the correct modtime at that point too. Sound OK?
> 

I reverted my previous commit and implemented it as you said.

> What we want is something like this
> 
> https://github.com/rclone/rclone/blob/4444037f5cf67b437ca242f5801adc0be249a13f/fs/accounting/accounting.go#L564
> 
> That wraps an `io.ReadSeeker` instead.
> 
> I'd be happy to leave a FIXME in the code and address this later if you want.

I tried that approach, but I couldn't make it work correctly, I think because of the seeks+reads. 
For the S3 backend, we do at least one seek (+re-read) when calculating the chunk's md5, but it seems that the AWS SDK also makes extra seeks behind the scenes (noticed this during debugging).
For example: https://github.com/aws/aws-sdk-go/blob/main/aws/request/request.go#L307
So, for now I left the FIXME as you suggested. 😅

Finally, I had to add a new flag "multi-thread-chunk-size" to set the chunk size for the "openChunkWriter with writerAt" adapter.

Do you think we could merge this PR? If yes, should I rebase/squash some of the commits?
ncw: > > Amazing performance :-) Is 2.5 GiB/s network saturated? As we are doing down and up or would 5GiB/s be network saturated?
> 
> I think 50 Gbps is the aggregate bandwidth: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-network-bandwidth.html That would explain the speed, but honestly I'm not 100% sure... sweat_smile

I think from reading the doc that yes we are saturating the network :-)

> > I think avoiding the SetModTime at the end if the Fs we are copying to does not have the `f.Features().Partial` feature flag set is probably the right thing to do. Fs without `Partial` only have the complete file at the end so must get the correct modtime at that point too. Sound OK?
> 
> I reverted my previous commit and implemented it as you said.

:+1:
 
> > What we want is something like this
> > https://github.com/rclone/rclone/blob/4444037f5cf67b437ca242f5801adc0be249a13f/fs/accounting/accounting.go#L564
> > 
> > That wraps an `io.ReadSeeker` instead.
> > I'd be happy to leave a FIXME in the code and address this later if you want.
> 
> I tried that approach, but I couldn't make it work correctly, I think because of the seeks+reads. For the S3 backend, we do at least one seek (+re-read) when calculating the chunk's md5, but it seems that the AWS SDK also makes extra seeks behind the scenes (noticed this during debugging). For example: https://github.com/aws/aws-sdk-go/blob/main/aws/request/request.go#L307 So, for now I left the FIXME as you suggested. sweat_smile

That's fine. Can't fix all of the world's problems in one PR :-)

> Finally, I had to add a new flag "multi-thread-chunk-size" to set the chunk size for the "openChunkWriter with writerAt" adapter.
> 
> Do you think we could merge this PR? If yes, should I rebase/squash some of the commits?

I think we are heading towards merge yes :-)

Can you rebase off master and then squash into logical changes like the plan we originally agreed. That would be perfect :-)

I think what I'd like to do then is pull this locally for the last review. I'll fix up any little things I notice when doing the final review if that is OK with you? Then I'll merge.

We can then work on the next bits!


vitorog: > 
> I think we are heading towards merge yes :-)
> 
> Can you rebase off master and then squash into logical changes like the plan we originally agreed. That would be perfect :-)
> 
> I think what I'd like to do then is pull this locally for the last review. I'll fix up any little things I notice when doing the final review if that is OK with you? Then I'll merge.
> 
> We can then work on the next bits!

Sounds awesome, thanks!
I rebased and squashed the commits.

Patch:
diff --git a/.gitignore b/.gitignore
index ff61403f3..0dd372896 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,10 +8,10 @@ rclone.iml
 .idea
 .history
 *.test
-*.log
 *.iml
 fuzz-build.zip
 *.orig
 *.rej
 Thumbs.db
 __pycache__
+.DS_Store
\ No newline at end of file
diff --git a/backend/fichier/api.go b/backend/fichier/api.go
index c0e3843dc..8e1bdbb61 100644
--- a/backend/fichier/api.go
+++ b/backend/fichier/api.go
@@ -28,14 +28,14 @@ var retryErrorCodes = []int{
 	509, // Bandwidth Limit Exceeded
 }
 
-var errorRegex = regexp.MustCompile(`#\d{1,3}`)
+var errorRegex = regexp.MustCompile(`#(\d{1,3})`)
 
 func parseFichierError(err error) int {
 	matches := errorRegex.FindStringSubmatch(err.Error())
 	if len(matches) == 0 {
 		return 0
 	}
-	code, err := strconv.Atoi(matches[0])
+	code, err := strconv.Atoi(matches[1])
 	if err != nil {
 		fs.Debugf(nil, "failed parsing fichier error: %v", err)
 		return 0
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index 07ed4c384..d68b340ba 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -5316,15 +5316,23 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
 
 var warnStreamUpload sync.Once
 
-func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, size int64, in io.Reader) (wantETag, gotETag string, versionID *string, err error) {
-	f := o.fs
+func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (chunkSizeResult int64, writer fs.ChunkWriter, err error) {
 
-	// make concurrency machinery
-	concurrency := f.opt.UploadConcurrency
-	if concurrency < 1 {
-		concurrency = 1
+	// This duplicates part of the logic in Update,
+	//however per my understanding it is required until we migrate the MultiPartUpload to OpenChunkWriter/multi-thread op completely
+	// Temporary Object under construction
+	o := &Object{
+		fs:     f,
+		remote: remote,
 	}
-	tokens := pacer.NewTokenDispenser(concurrency)
+	req, _, err := o.buildS3Req(ctx, src, options)
+	if err != nil {
+		return -1, nil, fmt.Errorf("failed to build s3 request: %v", err)
+	}
+
+	//structs.SetFrom(&mReq, req)
+	var mReq s3.CreateMultipartUploadInput
+	setFrom_s3CreateMultipartUploadInput_s3PutObjectInput(&mReq, req)
 
 	uploadParts := f.opt.MaxUploadParts
 	if uploadParts < 1 {
@@ -5332,9 +5340,10 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 	} else if uploadParts > maxUploadParts {
 		uploadParts = maxUploadParts
 	}
+	size := src.Size()
 
 	// calculate size of parts
-	partSize := f.opt.ChunkSize
+	chunkSize := f.opt.ChunkSize
 
 	// size can be -1 here meaning we don't know the size of the incoming file. We use ChunkSize
 	// buffers here (default 5 MiB). With a maximum number of parts (10,000) this will be a file of
@@ -5342,28 +5351,184 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 	if size == -1 {
 		warnStreamUpload.Do(func() {
 			fs.Logf(f, "Streaming uploads using chunk size %v will have maximum file size of %v",
-				f.opt.ChunkSize, fs.SizeSuffix(int64(partSize)*int64(uploadParts)))
+				f.opt.ChunkSize, fs.SizeSuffix(int64(chunkSize)*int64(uploadParts)))
 		})
 	} else {
-		partSize = chunksize.Calculator(o, size, uploadParts, f.opt.ChunkSize)
+		chunkSize = chunksize.Calculator(src, size, uploadParts, chunkSize)
 	}
 
-	memPool := f.getMemoryPool(int64(partSize))
+	mOut, err := f.c.CreateMultipartUploadWithContext(ctx, &mReq)
+	if err != nil {
+		return -1, nil, fmt.Errorf("CreateMultipartUpload failed: %w", err)
+	}
+
+	chunkWriter := &s3ChunkWriter{
+		ctx:                  ctx,
+		chunkSize:            int64(chunkSize),
+		size:                 src.Size(),
+		f:                    f,
+		bucket:               mOut.Bucket,
+		key:                  mOut.Key,
+		uploadId:             mOut.UploadId,
+		multiPartUploadInput: &mReq,
+		completedParts:       make([]*s3.CompletedPart, 0),
+	}
+	fs.Debugf(f, "open chunk writer: started multipart upload: %v", *mOut.UploadId)
+	return int64(chunkSize), chunkWriter, err
+}
 
-	var mReq s3.CreateMultipartUploadInput
-	//structs.SetFrom(&mReq, req)
-	setFrom_s3CreateMultipartUploadInput_s3PutObjectInput(&mReq, req)
-	var cout *s3.CreateMultipartUploadOutput
+type s3ChunkWriter struct {
+	ctx                  context.Context
+	chunkSize            int64
+	size                 int64
+	f                    *Fs
+	bucket               *string
+	key                  *string
+	uploadId             *string
+	multiPartUploadInput *s3.CreateMultipartUploadInput
+	completedPartsMu     sync.Mutex
+	completedParts       []*s3.CompletedPart
+	eTag                 string
+	versionID            string
+	md5sMu               sync.Mutex
+	md5s                 []byte
+}
+
+func (w *s3ChunkWriter) WriteChunk(chunkNumber int, reader io.ReadSeeker) (int64, error) {
+	if chunkNumber < 0 {
+		err := fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
+		return -1, err
+	}
+
+	addMd5 := func(md5binary *[]byte, chunkNumber int64) {
+		w.md5sMu.Lock()
+		defer w.md5sMu.Unlock()
+		start := chunkNumber * md5.Size
+		end := start + md5.Size
+		if extend := end - int64(len(w.md5s)); extend > 0 {
+			w.md5s = append(w.md5s, make([]byte, extend)...)
+		}
+		copy(w.md5s[start:end], (*md5binary)[:])
+	}
+
+	// create checksum of buffer for integrity checking
+	// currently there is no way to calculate the md5 without reading the chunk a 2nd time (1st read is in uploadMultipart)
+	// possible in AWS SDK v2 with trailers?
+	m := md5.New()
+	currentChunkSize, err := io.Copy(m, reader)
+	if err != nil && err != io.EOF {
+		return -1, err
+	}
+	md5sumBinary := m.Sum([]byte{})
+	addMd5(&md5sumBinary, int64(chunkNumber))
+	md5sum := base64.StdEncoding.EncodeToString(md5sumBinary[:])
+
+	// reset the reader after we calculated the md5
+	_, err = reader.Seek(0, io.SeekStart)
+	if err != nil {
+		return -1, err
+	}
+
+	// S3 requires 1 <= PartNumber <= 10000
+	s3PartNumber := aws.Int64(int64(chunkNumber + 1))
+	uploadPartReq := &s3.UploadPartInput{
+		Body:                 reader,
+		Bucket:               w.bucket,
+		Key:                  w.key,
+		PartNumber:           s3PartNumber,
+		UploadId:             w.uploadId,
+		ContentMD5:           &md5sum,
+		ContentLength:        aws.Int64(currentChunkSize),
+		RequestPayer:         w.multiPartUploadInput.RequestPayer,
+		SSECustomerAlgorithm: w.multiPartUploadInput.SSECustomerAlgorithm,
+		SSECustomerKey:       w.multiPartUploadInput.SSECustomerKey,
+		SSECustomerKeyMD5:    w.multiPartUploadInput.SSECustomerKeyMD5,
+	}
+	uout, err := w.f.c.UploadPartWithContext(w.ctx, uploadPartReq)
+	if err != nil {
+		fs.Errorf(w.f, "Failed to upload part: %v", err)
+		return -1, err
+	}
+
+	addCompletedPart := func(partNum *int64, eTag *string) {
+		w.completedPartsMu.Lock()
+		defer w.completedPartsMu.Unlock()
+		w.completedParts = append(w.completedParts, &s3.CompletedPart{
+			PartNumber: partNum,
+			ETag:       uout.ETag,
+		})
+	}
+	addCompletedPart(s3PartNumber, uout.ETag)
+
+	fs.Debugf(w.f, "multipart upload wrote chunk %d with %v bytes and etag %v", chunkNumber+1, currentChunkSize, *uout.ETag)
+	return currentChunkSize, err
+}
+
+func (w *s3ChunkWriter) Abort() error {
+	_, err := w.f.c.AbortMultipartUploadWithContext(context.Background(), &s3.AbortMultipartUploadInput{
+		Bucket:       w.bucket,
+		Key:          w.key,
+		UploadId:     w.uploadId,
+		RequestPayer: w.multiPartUploadInput.RequestPayer,
+	})
+	if err != nil {
+		fs.Errorf(w.f, "Failed to abort multipart upload: %v", err)
+	}
+	fs.Debugf(w.f, "multipart upload '%v' aborted", *w.uploadId)
+	return err
+}
+
+func (w *s3ChunkWriter) Close() error {
+	// sort the completed parts by part number
+	sort.Slice(w.completedParts, func(i, j int) bool {
+		return *w.completedParts[i].PartNumber < *w.completedParts[j].PartNumber
+	})
+	resp, err := w.f.c.CompleteMultipartUploadWithContext(w.ctx, &s3.CompleteMultipartUploadInput{
+		Bucket: w.bucket,
+		Key:    w.key,
+		MultipartUpload: &s3.CompletedMultipartUpload{
+			Parts: w.completedParts,
+		},
+		RequestPayer: w.multiPartUploadInput.RequestPayer,
+		UploadId:     w.uploadId,
+	})
+	if err != nil {
+		fs.Errorf(w.f, "Failed to complete multipart upload: %v", err)
+	}
+	if resp != nil {
+		if resp.ETag != nil {
+			w.eTag = *resp.ETag
+		}
+		if resp.VersionId != nil {
+			w.versionID = *resp.VersionId
+		}
+	}
+	fs.Debugf(w.f, "multipart upload '%v' closed", *w.uploadId)
+	return err
+}
+
+func (o *Object) uploadMultipart(ctx context.Context, src fs.ObjectInfo, in io.Reader) (wantETag, gotETag string, versionID *string, err error) {
+	f := o.fs
+
+	// make concurrency machinery
+	concurrency := f.opt.UploadConcurrency
+	if concurrency < 1 {
+		concurrency = 1
+	}
+	tokens := pacer.NewTokenDispenser(concurrency)
+	openChunkWriter := f.Features().OpenChunkWriter
+
+	var chunkWriter fs.ChunkWriter
+	var chunkSize int64
 	err = f.pacer.Call(func() (bool, error) {
 		var err error
-		cout, err = f.c.CreateMultipartUploadWithContext(ctx, &mReq)
+		chunkSize, chunkWriter, err = openChunkWriter(ctx, src.Remote(), src)
 		return f.shouldRetry(ctx, err)
 	})
 	if err != nil {
 		return wantETag, gotETag, nil, fmt.Errorf("multipart upload failed to initialise: %w", err)
 	}
-	uid := cout.UploadId
-
+	memPool := f.getMemoryPool(chunkSize)
 	uploadCtx, cancel := context.WithCancel(ctx)
 	defer atexit.OnError(&err, func() {
 		cancel()
@@ -5372,12 +5537,7 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 		}
 		fs.Debugf(o, "Cancelling multipart upload")
 		errCancel := f.pacer.Call(func() (bool, error) {
-			_, err := f.c.AbortMultipartUploadWithContext(context.Background(), &s3.AbortMultipartUploadInput{
-				Bucket:       req.Bucket,
-				Key:          req.Key,
-				UploadId:     uid,
-				RequestPayer: req.RequestPayer,
-			})
+			err := chunkWriter.Abort()
 			return f.shouldRetry(ctx, err)
 		})
 		if errCancel != nil {
@@ -5388,25 +5548,10 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 	var (
 		g, gCtx  = errgroup.WithContext(uploadCtx)
 		finished = false
-		partsMu  sync.Mutex // to protect parts
-		parts    []*s3.CompletedPart
 		off      int64
-		md5sMu   sync.Mutex
-		md5s     []byte
 	)
 
-	addMd5 := func(md5binary *[md5.Size]byte, partNum int64) {
-		md5sMu.Lock()
-		defer md5sMu.Unlock()
-		start := partNum * md5.Size
-		end := start + md5.Size
-		if extend := end - int64(len(md5s)); extend > 0 {
-			md5s = append(md5s, make([]byte, extend)...)
-		}
-		copy(md5s[start:end], (*md5binary)[:])
-	}
-
-	for partNum := int64(1); !finished; partNum++ {
+	for partNum := int64(0); !finished; partNum++ {
 		// Get a block of memory from the pool and token which limits concurrency.
 		tokens.Get()
 		buf := memPool.Get()
@@ -5428,7 +5573,7 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 		var n int
 		n, err = readers.ReadFill(in, buf) // this can never return 0, nil
 		if err == io.EOF {
-			if n == 0 && partNum != 1 { // end if no data and if not first chunk
+			if n == 0 && partNum != 0 { // end if no data and if not first chunk
 				free()
 				break
 			}
@@ -5440,32 +5585,12 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 		buf = buf[:n]
 
 		partNum := partNum
-		fs.Debugf(o, "multipart upload starting chunk %d size %v offset %v/%v", partNum, fs.SizeSuffix(n), fs.SizeSuffix(off), fs.SizeSuffix(size))
+		fs.Debugf(o, "multipart upload starting chunk %d size %v offset %v/%v", partNum, fs.SizeSuffix(n), fs.SizeSuffix(off), fs.SizeSuffix(src.Size()))
 		off += int64(n)
 		g.Go(func() (err error) {
 			defer free()
-			partLength := int64(len(buf))
-
-			// create checksum of buffer for integrity checking
-			md5sumBinary := md5.Sum(buf)
-			addMd5(&md5sumBinary, partNum-1)
-			md5sum := base64.StdEncoding.EncodeToString(md5sumBinary[:])
-
 			err = f.pacer.Call(func() (bool, error) {
-				uploadPartReq := &s3.UploadPartInput{
-					Body:                 bytes.NewReader(buf),
-					Bucket:               req.Bucket,
-					Key:                  req.Key,
-					PartNumber:           &partNum,
-					UploadId:             uid,
-					ContentMD5:           &md5sum,
-					ContentLength:        &partLength,
-					RequestPayer:         req.RequestPayer,
-					SSECustomerAlgorithm: req.SSECustomerAlgorithm,
-					SSECustomerKey:       req.SSECustomerKey,
-					SSECustomerKeyMD5:    req.SSECustomerKeyMD5,
-				}
-				uout, err := f.c.UploadPartWithContext(gCtx, uploadPartReq)
+				_, err := chunkWriter.WriteChunk(int(partNum), bytes.NewReader(buf))
 				if err != nil {
 					if partNum <= int64(concurrency) {
 						return f.shouldRetry(gCtx, err)
@@ -5473,13 +5598,6 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 					// retry all chunks once have done the first batch
 					return true, err
 				}
-				partsMu.Lock()
-				parts = append(parts, &s3.CompletedPart{
-					PartNumber: &partNum,
-					ETag:       uout.ETag,
-				})
-				partsMu.Unlock()
-
 				return false, nil
 			})
 			if err != nil {
@@ -5493,35 +5611,21 @@ func (o *Object) uploadMultipart(ctx context.Context, req *s3.PutObjectInput, si
 		return wantETag, gotETag, nil, err
 	}
 
-	// sort the completed parts by part number
-	sort.Slice(parts, func(i, j int) bool {
-		return *parts[i].PartNumber < *parts[j].PartNumber
-	})
-
-	var resp *s3.CompleteMultipartUploadOutput
 	err = f.pacer.Call(func() (bool, error) {
-		resp, err = f.c.CompleteMultipartUploadWithContext(uploadCtx, &s3.CompleteMultipartUploadInput{
-			Bucket: req.Bucket,
-			Key:    req.Key,
-			MultipartUpload: &s3.CompletedMultipartUpload{
-				Parts: parts,
-			},
-			RequestPayer: req.RequestPayer,
-			UploadId:     uid,
-		})
+		err := chunkWriter.Close()
 		return f.shouldRetry(uploadCtx, err)
 	})
 	if err != nil {
 		return wantETag, gotETag, nil, fmt.Errorf("multipart upload failed to finalise: %w", err)
 	}
-	hashOfHashes := md5.Sum(md5s)
-	wantETag = fmt.Sprintf("%s-%d", hex.EncodeToString(hashOfHashes[:]), len(parts))
-	if resp != nil {
-		if resp.ETag != nil {
-			gotETag = *resp.ETag
-		}
-		versionID = resp.VersionId
-	}
+
+	var s3cw *s3ChunkWriter = chunkWriter.(*s3ChunkWriter)
+	gotETag = s3cw.eTag
+	versionID = aws.String(s3cw.versionID)
+
+	hashOfHashes := md5.Sum(s3cw.md5s)
+	wantETag = fmt.Sprintf("%s-%d", hex.EncodeToString(hashOfHashes[:]), len(s3cw.completedParts))
+
 	return wantETag, gotETag, versionID, nil
 }
 
@@ -5652,25 +5756,18 @@ func (o *Object) uploadSinglepartPresignedRequest(ctx context.Context, req *s3.P
 	return etag, lastModified, versionID, nil
 }
 
-// Update the Object from in with modTime and size
-func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
-	if o.fs.opt.VersionAt.IsSet() {
-		return errNotWithVersionAt
-	}
+func (o *Object) buildS3Req(ctx context.Context, src fs.ObjectInfo, options []fs.OpenOption) (req *s3.PutObjectInput, md5sumHex string, err error) {
 	bucket, bucketPath := o.split()
 	// Create parent dir/bucket if not saving directory marker
 	if !strings.HasSuffix(o.remote, "/") {
 		err := o.fs.mkdirParent(ctx, o.remote)
 		if err != nil {
-			return err
+			return nil, "", err
 		}
 	}
 	modTime := src.ModTime(ctx)
-	size := src.Size()
-
-	multipart := size < 0 || size >= int64(o.fs.opt.UploadCutoff)
 
-	req := s3.PutObjectInput{
+	req = &s3.PutObjectInput{
 		Bucket: &bucket,
 		ACL:    stringPointerOrNil(o.fs.opt.ACL),
 		Key:    &bucketPath,
@@ -5679,7 +5776,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	// Fetch metadata if --metadata is in use
 	meta, err := fs.GetMetadataOptions(ctx, src, options)
 	if err != nil {
-		return fmt.Errorf("failed to read metadata from source object: %w", err)
+		return nil, "", fmt.Errorf("failed to read metadata from source object: %w", err)
 	}
 	req.Metadata = make(map[string]*string, len(meta)+2)
 	// merge metadata into request and user metadata
@@ -5731,7 +5828,8 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	// - for multipart provided checksums aren't disabled
 	//    - so we can add the md5sum in the metadata as metaMD5Hash
 	var md5sumBase64 string
-	var md5sumHex string
+	size := src.Size()
+	multipart := size < 0 || size >= int64(o.fs.opt.UploadCutoff)
 	if !multipart || !o.fs.opt.DisableChecksum {
 		md5sumHex, err = src.Hash(ctx, hash.MD5)
 		if err == nil && matchMd5.MatchString(md5sumHex) {
@@ -5749,7 +5847,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		}
 	}
 
-	// Set the content type it it isn't set already
+	// Set the content type if it isn't set already
 	if req.ContentType == nil {
 		req.ContentType = aws.String(fs.MimeType(ctx, src))
 	}
@@ -5824,17 +5922,36 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		}
 	}
 
+	return req, md5sumHex, nil
+}
+
+// Update the Object from in with modTime and size
+func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
+	if o.fs.opt.VersionAt.IsSet() {
+		return errNotWithVersionAt
+	}
+	size := src.Size()
+	multipart := size < 0 || size >= int64(o.fs.opt.UploadCutoff)
+
 	var wantETag string        // Multipart upload Etag to check
 	var gotETag string         // Etag we got from the upload
 	var lastModified time.Time // Time we got from the upload
 	var versionID *string      // versionID we got from the upload
+	var err error
+	var md5sumHex string
+	var req *s3.PutObjectInput
 	if multipart {
-		wantETag, gotETag, versionID, err = o.uploadMultipart(ctx, &req, size, in)
+		wantETag, gotETag, versionID, err = o.uploadMultipart(ctx, src, in)
 	} else {
+		req, md5sumHex, err = o.buildS3Req(ctx, src, options)
+		if err != nil {
+			return fmt.Errorf("failed to build s3 request: %v", err)
+		}
+
 		if o.fs.opt.UsePresignedRequest {
-			gotETag, lastModified, versionID, err = o.uploadSinglepartPresignedRequest(ctx, &req, size, in)
+			gotETag, lastModified, versionID, err = o.uploadSinglepartPresignedRequest(ctx, req, size, in)
 		} else {
-			gotETag, lastModified, versionID, err = o.uploadSinglepartPutObject(ctx, &req, size, in)
+			gotETag, lastModified, versionID, err = o.uploadSinglepartPutObject(ctx, req, size, in)
 		}
 	}
 	if err != nil {
@@ -5854,7 +5971,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	if o.fs.opt.NoHead && size >= 0 {
 		head = new(s3.HeadObjectOutput)
 		//structs.SetFrom(head, &req)
-		setFrom_s3HeadObjectOutput_s3PutObjectInput(head, &req)
+		setFrom_s3HeadObjectOutput_s3PutObjectInput(head, req)
 		head.ETag = &md5sumHex // doesn't matter quotes are missing
 		head.ContentLength = &size
 		// We get etag back from single and multipart upload so fill it in here
diff --git a/cmd/bisync/cmd.go b/cmd/bisync/cmd.go
index 17c87f1cc..1a4866035 100644
--- a/cmd/bisync/cmd.go
+++ b/cmd/bisync/cmd.go
@@ -27,18 +27,21 @@ import (
 
 // Options keep bisync options
 type Options struct {
-	Resync          bool
-	CheckAccess     bool
-	CheckFilename   string
-	CheckSync       CheckSyncMode
-	RemoveEmptyDirs bool
-	MaxDelete       int // percentage from 0 to 100
-	Force           bool
-	FiltersFile     string
-	Workdir         string
-	DryRun          bool
-	NoCleanup       bool
-	SaveQueues      bool // save extra debugging files (test only flag)
+	Resync                bool
+	CheckAccess           bool
+	CheckFilename         string
+	CheckSync             CheckSyncMode
+	CreateEmptySrcDirs    bool
+	RemoveEmptyDirs       bool
+	MaxDelete             int // percentage from 0 to 100
+	Force                 bool
+	FiltersFile           string
+	Workdir               string
+	DryRun                bool
+	NoCleanup             bool
+	SaveQueues            bool // save extra debugging files (test only flag)
+	IgnoreListingChecksum bool
+	Resilient             bool
 }
 
 // Default values
@@ -103,11 +106,14 @@ func init() {
 	flags.StringVarP(cmdFlags, &Opt.CheckFilename, "check-filename", "", Opt.CheckFilename, makeHelp("Filename for --check-access (default: {CHECKFILE})"), "")
 	flags.BoolVarP(cmdFlags, &Opt.Force, "force", "", Opt.Force, "Bypass --max-delete safety check and run the sync. Consider using with --verbose", "")
 	flags.FVarP(cmdFlags, &Opt.CheckSync, "check-sync", "", "Controls comparison of final listings: true|false|only (default: true)", "")
-	flags.BoolVarP(cmdFlags, &Opt.RemoveEmptyDirs, "remove-empty-dirs", "", Opt.RemoveEmptyDirs, "Remove empty directories at the final cleanup step.", "")
+	flags.BoolVarP(cmdFlags, &Opt.CreateEmptySrcDirs, "create-empty-src-dirs", "", Opt.CreateEmptySrcDirs, "Sync creation and deletion of empty directories. (Not compatible with --remove-empty-dirs)", "")
+	flags.BoolVarP(cmdFlags, &Opt.RemoveEmptyDirs, "remove-empty-dirs", "", Opt.RemoveEmptyDirs, "Remove ALL empty directories at the final cleanup step.", "")
 	flags.StringVarP(cmdFlags, &Opt.FiltersFile, "filters-file", "", Opt.FiltersFile, "Read filtering patterns from a file", "")
 	flags.StringVarP(cmdFlags, &Opt.Workdir, "workdir", "", Opt.Workdir, makeHelp("Use custom working dir - useful for testing. (default: {WORKDIR})"), "")
 	flags.BoolVarP(cmdFlags, &tzLocal, "localtime", "", tzLocal, "Use local time in listings (default: UTC)", "")
 	flags.BoolVarP(cmdFlags, &Opt.NoCleanup, "no-cleanup", "", Opt.NoCleanup, "Retain working files (useful for troubleshooting and testing).", "")
+	flags.BoolVarP(cmdFlags, &Opt.IgnoreListingChecksum, "ignore-listing-checksum", "", Opt.IgnoreListingChecksum, "Do not use checksums for listings (add --ignore-checksum to additionally skip post-copy checksum checks)", "")
+	flags.BoolVarP(cmdFlags, &Opt.Resilient, "resilient", "", Opt.Resilient, "Allow future runs to retry after certain less-serious errors, instead of requiring --resync. Use at your own risk!", "")
 }
 
 // bisync command definition
@@ -210,9 +216,13 @@ func (opt *Options) applyFilters(ctx context.Context) (context.Context, error) {
 	}
 
 	if opt.Resync {
-		fs.Infof(nil, "Storing filters file hash to %s", hashFile)
-		if err := os.WriteFile(hashFile, []byte(gotHash), bilib.PermSecure); err != nil {
-			return ctx, err
+		if opt.DryRun {
+			fs.Infof(nil, "Skipped storing filters file hash to %s as --dry-run is set", hashFile)
+		} else {
+			fs.Infof(nil, "Storing filters file hash to %s", hashFile)
+			if err := os.WriteFile(hashFile, []byte(gotHash), bilib.PermSecure); err != nil {
+				return ctx, err
+			}
 		}
 	}
 
diff --git a/cmd/bisync/deltas.go b/cmd/bisync/deltas.go
index d1dee1a1d..ccd43edbe 100644
--- a/cmd/bisync/deltas.go
+++ b/cmd/bisync/deltas.go
@@ -3,13 +3,18 @@
 package bisync
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"path/filepath"
 	"sort"
+	"strings"
 
 	"github.com/rclone/rclone/cmd/bisync/bilib"
+	"github.com/rclone/rclone/cmd/check"
 	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/accounting"
+	"github.com/rclone/rclone/fs/filter"
 	"github.com/rclone/rclone/fs/operations"
 )
 
@@ -90,6 +95,47 @@ func (ds *deltaSet) printStats() {
 		ds.msg, nAll, nNew, nNewer, nOlder, nDeleted)
 }
 
+// check potential conflicts (to avoid renaming if already identical)
+func (b *bisyncRun) checkconflicts(ctxCheck context.Context, filterCheck *filter.Filter, fs1, fs2 fs.Fs) (bilib.Names, error) {
+	matches := bilib.Names{}
+	if filterCheck.HaveFilesFrom() {
+		fs.Debugf(nil, "There are potential conflicts to check.")
+
+		opt, close, checkopterr := check.GetCheckOpt(b.fs1, b.fs2)
+		if checkopterr != nil {
+			b.critical = true
+			b.retryable = true
+			fs.Debugf(nil, "GetCheckOpt error: %v", checkopterr)
+			return matches, checkopterr
+		}
+		defer close()
+
+		opt.Match = new(bytes.Buffer)
+
+		// TODO: consider using custom CheckFn to act like cryptcheck, if either fs is a crypt remote and -c has been passed
+		// note that cryptCheck() is not currently exported
+
+		fs.Infof(nil, "Checking potential conflicts...")
+		check := operations.Check(ctxCheck, opt)
+		fs.Infof(nil, "Finished checking the potential conflicts. %s", check)
+
+		//reset error count, because we don't want to count check errors as bisync errors
+		accounting.Stats(ctxCheck).ResetErrors()
+
+		//return the list of identical files to check against later
+		if len(fmt.Sprint(opt.Match)) > 0 {
+			matches = bilib.ToNames(strings.Split(fmt.Sprint(opt.Match), "\n"))
+		}
+		if matches.NotEmpty() {
+			fs.Debugf(nil, "The following potential conflicts were determined to be identical. %v", matches)
+		} else {
+			fs.Debugf(nil, "None of the conflicts were determined to be identical.")
+		}
+
+	}
+	return matches, nil
+}
+
 // findDeltas
 func (b *bisyncRun) findDeltas(fctx context.Context, f fs.Fs, oldListing, newListing, msg string) (ds *deltaSet, err error) {
 	var old, now *fileList
@@ -183,6 +229,52 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 
 	ctxMove := b.opt.setDryRun(ctx)
 
+	// efficient isDir check
+	// we load the listing just once and store only the dirs
+	dirs1, dirs1Err := b.listDirsOnly(1)
+	if dirs1Err != nil {
+		b.critical = true
+		b.retryable = true
+		fs.Debugf(nil, "Error generating dirsonly list for path1: %v", dirs1Err)
+		return
+	}
+
+	dirs2, dirs2Err := b.listDirsOnly(2)
+	if dirs2Err != nil {
+		b.critical = true
+		b.retryable = true
+		fs.Debugf(nil, "Error generating dirsonly list for path2: %v", dirs2Err)
+		return
+	}
+
+	// build a list of only the "deltaOther"s so we don't have to check more files than necessary
+	// this is essentially the same as running rclone check with a --files-from filter, then exempting the --match results from being renamed
+	// we therefore avoid having to list the same directory more than once.
+
+	// we are intentionally overriding DryRun here because we need to perform the check, even during a dry run, or the results would be inaccurate.
+	// check is a read-only operation by its nature, so it's already "dry" in that sense.
+	ctxNew, ciCheck := fs.AddConfig(ctx)
+	ciCheck.DryRun = false
+
+	ctxCheck, filterCheck := filter.AddConfig(ctxNew)
+
+	for _, file := range ds1.sort() {
+		d1 := ds1.deltas[file]
+		if d1.is(deltaOther) {
+			d2 := ds2.deltas[file]
+			if d2.is(deltaOther) {
+				if err := filterCheck.AddFile(file); err != nil {
+					fs.Debugf(nil, "Non-critical error adding file to list of potential conflicts to check: %s", err)
+				} else {
+					fs.Debugf(nil, "Added file to list of potential conflicts to check: %s", file)
+				}
+			}
+		}
+	}
+
+	//if there are potential conflicts to check, check them all here (outside the loop) in one fell swoop
+	matches, err := b.checkconflicts(ctxCheck, filterCheck, b.fs1, b.fs2)
+
 	for _, file := range ds1.sort() {
 		p1 := path1 + file
 		p2 := path2 + file
@@ -199,22 +291,34 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 				handled.Add(file)
 			} else if d2.is(deltaOther) {
 				b.indent("!WARNING", file, "New or changed in both paths")
-				b.indent("!Path1", p1+"..path1", "Renaming Path1 copy")
-				if err = operations.MoveFile(ctxMove, b.fs1, b.fs1, file+"..path1", file); err != nil {
-					err = fmt.Errorf("path1 rename failed for %s: %w", p1, err)
-					b.critical = true
-					return
-				}
-				b.indent("!Path1", p2+"..path1", "Queue copy to Path2")
-				copy1to2.Add(file + "..path1")
 
-				b.indent("!Path2", p2+"..path2", "Renaming Path2 copy")
-				if err = operations.MoveFile(ctxMove, b.fs2, b.fs2, file+"..path2", file); err != nil {
-					err = fmt.Errorf("path2 rename failed for %s: %w", file, err)
-					return
+				//if files are identical, leave them alone instead of renaming
+				if dirs1.has(file) && dirs2.has(file) {
+					fs.Debugf(nil, "This is a directory, not a file. Skipping equality check and will not rename: %s", file)
+				} else {
+					equal := matches.Has(file)
+					if equal {
+						fs.Infof(nil, "Files are equal! Skipping: %s", file)
+					} else {
+						fs.Debugf(nil, "Files are NOT equal: %s", file)
+						b.indent("!Path1", p1+"..path1", "Renaming Path1 copy")
+						if err = operations.MoveFile(ctxMove, b.fs1, b.fs1, file+"..path1", file); err != nil {
+							err = fmt.Errorf("path1 rename failed for %s: %w", p1, err)
+							b.critical = true
+							return
+						}
+						b.indent("!Path1", p2+"..path1", "Queue copy to Path2")
+						copy1to2.Add(file + "..path1")
+
+						b.indent("!Path2", p2+"..path2", "Renaming Path2 copy")
+						if err = operations.MoveFile(ctxMove, b.fs2, b.fs2, file+"..path2", file); err != nil {
+							err = fmt.Errorf("path2 rename failed for %s: %w", file, err)
+							return
+						}
+						b.indent("!Path2", p1+"..path2", "Queue copy to Path1")
+						copy2to1.Add(file + "..path2")
+					}
 				}
-				b.indent("!Path2", p1+"..path2", "Queue copy to Path1")
-				copy2to1.Add(file + "..path2")
 				handled.Add(file)
 			}
 		} else {
@@ -258,6 +362,9 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 		if err != nil {
 			return
 		}
+
+		//copy empty dirs from path2 to path1 (if --create-empty-src-dirs)
+		b.syncEmptyDirs(ctx, b.fs1, copy2to1, dirs2, "make")
 	}
 
 	if copy1to2.NotEmpty() {
@@ -267,6 +374,9 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 		if err != nil {
 			return
 		}
+
+		//copy empty dirs from path1 to path2 (if --create-empty-src-dirs)
+		b.syncEmptyDirs(ctx, b.fs2, copy1to2, dirs1, "make")
 	}
 
 	if delete1.NotEmpty() {
@@ -276,6 +386,9 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 		if err != nil {
 			return
 		}
+
+		//propagate deletions of empty dirs from path2 to path1 (if --create-empty-src-dirs)
+		b.syncEmptyDirs(ctx, b.fs1, delete1, dirs1, "remove")
 	}
 
 	if delete2.NotEmpty() {
@@ -285,6 +398,9 @@ func (b *bisyncRun) applyDeltas(ctx context.Context, ds1, ds2 *deltaSet) (change
 		if err != nil {
 			return
 		}
+
+		//propagate deletions of empty dirs from path1 to path2 (if --create-empty-src-dirs)
+		b.syncEmptyDirs(ctx, b.fs2, delete2, dirs2, "remove")
 	}
 
 	return
diff --git a/cmd/bisync/listing.go b/cmd/bisync/listing.go
index 066b927e8..b2edb7ec9 100644
--- a/cmd/bisync/listing.go
+++ b/cmd/bisync/listing.go
@@ -43,10 +43,11 @@ var tzLocal = false
 
 // fileInfo describes a file
 type fileInfo struct {
-	size int64
-	time time.Time
-	hash string
-	id   string
+	size  int64
+	time  time.Time
+	hash  string
+	id    string
+	flags string
 }
 
 // fileList represents a listing
@@ -76,17 +77,18 @@ func (ls *fileList) get(file string) *fileInfo {
 	return ls.info[file]
 }
 
-func (ls *fileList) put(file string, size int64, time time.Time, hash, id string) {
+func (ls *fileList) put(file string, size int64, time time.Time, hash, id string, flags string) {
 	fi := ls.get(file)
 	if fi != nil {
 		fi.size = size
 		fi.time = time
 	} else {
 		fi = &fileInfo{
-			size: size,
-			time: time,
-			hash: hash,
-			id:   id,
+			size:  size,
+			time:  time,
+			hash:  hash,
+			id:    id,
+			flags: flags,
 		}
 		ls.info[file] = fi
 		ls.list = append(ls.list, file)
@@ -152,7 +154,11 @@ func (ls *fileList) save(ctx context.Context, listing string) error {
 			id = "-"
 		}
 
-		flags := "-"
+		flags := fi.flags
+		if flags == "" {
+			flags = "-"
+		}
+
 		_, err = fmt.Fprintf(file, lineFormat, flags, fi.size, hash, id, time, remote)
 		if err != nil {
 			_ = file.Close()
@@ -217,7 +223,7 @@ func (b *bisyncRun) loadListing(listing string) (*fileList, error) {
 			}
 		}
 
-		if flags != "-" || id != "-" || sizeErr != nil || timeErr != nil || hashErr != nil || nameErr != nil {
+		if (flags != "-" && flags != "d") || id != "-" || sizeErr != nil || timeErr != nil || hashErr != nil || nameErr != nil {
 			fs.Logf(listing, "Ignoring incorrect line: %q", line)
 			continue
 		}
@@ -229,7 +235,7 @@ func (b *bisyncRun) loadListing(listing string) (*fileList, error) {
 			}
 		}
 
-		ls.put(nameVal, sizeVal, timeVal.In(TZ), hashVal, id)
+		ls.put(nameVal, sizeVal, timeVal.In(TZ), hashVal, id, flags)
 	}
 
 	return ls, nil
@@ -253,15 +259,20 @@ func (b *bisyncRun) makeListing(ctx context.Context, f fs.Fs, listing string) (l
 	ci := fs.GetConfig(ctx)
 	depth := ci.MaxDepth
 	hashType := hash.None
-	if !ci.IgnoreChecksum {
-		// Currently bisync just honors --ignore-checksum
+	if !b.opt.IgnoreListingChecksum {
+		// Currently bisync just honors --ignore-listing-checksum
+		// (note that this is different from --ignore-checksum)
 		// TODO add full support for checksums and related flags
 		hashType = f.Hashes().GetOne()
 	}
 	ls = newFileList()
 	ls.hash = hashType
 	var lock sync.Mutex
-	err = walk.ListR(ctx, f, "", false, depth, walk.ListObjects, func(entries fs.DirEntries) error {
+	listType := walk.ListObjects
+	if b.opt.CreateEmptySrcDirs {
+		listType = walk.ListAll
+	}
+	err = walk.ListR(ctx, f, "", false, depth, listType, func(entries fs.DirEntries) error {
 		var firstErr error
 		entries.ForObject(func(o fs.Object) {
 			//tr := accounting.Stats(ctx).NewCheckingTransfer(o) // TODO
@@ -276,12 +287,27 @@ func (b *bisyncRun) makeListing(ctx context.Context, f fs.Fs, listing string) (l
 				}
 			}
 			time := o.ModTime(ctx).In(TZ)
-			id := "" // TODO
+			id := ""     // TODO
+			flags := "-" // "-" for a file and "d" for a directory
 			lock.Lock()
-			ls.put(o.Remote(), o.Size(), time, hashVal, id)
+			ls.put(o.Remote(), o.Size(), time, hashVal, id, flags)
 			lock.Unlock()
 			//tr.Done(ctx, nil) // TODO
 		})
+		if b.opt.CreateEmptySrcDirs {
+			entries.ForDir(func(o fs.Directory) {
+				var (
+					hashVal string
+				)
+				time := o.ModTime(ctx).In(TZ)
+				id := ""     // TODO
+				flags := "d" // "-" for a file and "d" for a directory
+				lock.Lock()
+				//record size as 0 instead of -1, so bisync doesn't think it's a google doc
+				ls.put(o.Remote(), 0, time, hashVal, id, flags)
+				lock.Unlock()
+			})
+		}
 		return firstErr
 	})
 	if err == nil {
@@ -300,5 +326,53 @@ func (b *bisyncRun) checkListing(ls *fileList, listing, msg string) error {
 	}
 	fs.Errorf(nil, "Empty %s listing. Cannot sync to an empty directory: %s", msg, listing)
 	b.critical = true
+	b.retryable = true
 	return fmt.Errorf("empty %s listing: %s", msg, listing)
 }
+
+// listingNum should be 1 for path1 or 2 for path2
+func (b *bisyncRun) loadListingNum(listingNum int) (*fileList, error) {
+	listingpath := b.basePath + ".path1.lst-new"
+	if listingNum == 2 {
+		listingpath = b.basePath + ".path2.lst-new"
+	}
+
+	if b.opt.DryRun {
+		listingpath = strings.Replace(listingpath, ".lst-", ".lst-dry-", 1)
+	}
+
+	fs.Debugf(nil, "loading listing for path %d at: %s", listingNum, listingpath)
+	return b.loadListing(listingpath)
+}
+
+func (b *bisyncRun) listDirsOnly(listingNum int) (*fileList, error) {
+	var fulllisting *fileList
+	var dirsonly = newFileList()
+	var err error
+
+	if !b.opt.CreateEmptySrcDirs {
+		return dirsonly, err
+	}
+
+	fulllisting, err = b.loadListingNum(listingNum)
+
+	if err != nil {
+		b.critical = true
+		b.retryable = true
+		fs.Debugf(nil, "Error loading listing to generate dirsonly list: %v", err)
+		return dirsonly, err
+	}
+
+	for _, obj := range fulllisting.list {
+		info := fulllisting.get(obj)
+
+		if info.flags == "d" {
+			fs.Debugf(nil, "found a dir: %s", obj)
+			dirsonly.put(obj, info.size, info.time, info.hash, info.id, info.flags)
+		} else {
+			fs.Debugf(nil, "not a dir: %s", obj)
+		}
+	}
+
+	return dirsonly, err
+}
diff --git a/cmd/bisync/operations.go b/cmd/bisync/operations.go
index 85b1e43d1..250437e62 100644
--- a/cmd/bisync/operations.go
+++ b/cmd/bisync/operations.go
@@ -25,13 +25,14 @@ var ErrBisyncAborted = errors.New("bisync aborted")
 
 // bisyncRun keeps bisync runtime state
 type bisyncRun struct {
-	fs1      fs.Fs
-	fs2      fs.Fs
-	abort    bool
-	critical bool
-	basePath string
-	workDir  string
-	opt      *Options
+	fs1       fs.Fs
+	fs2       fs.Fs
+	abort     bool
+	critical  bool
+	retryable bool
+	basePath  string
+	workDir   string
+	opt       *Options
 }
 
 // Bisync handles lock file, performs bisync run and checks exit status
@@ -123,14 +124,19 @@ func Bisync(ctx context.Context, fs1, fs2 fs.Fs, optArg *Options) (err error) {
 	}
 
 	if b.critical {
-		if bilib.FileExists(listing1) {
-			_ = os.Rename(listing1, listing1+"-err")
-		}
-		if bilib.FileExists(listing2) {
-			_ = os.Rename(listing2, listing2+"-err")
+		if b.retryable && b.opt.Resilient {
+			fs.Errorf(nil, "Bisync critical error: %v", err)
+			fs.Errorf(nil, "Bisync aborted. Error is retryable without --resync due to --resilient mode.")
+		} else {
+			if bilib.FileExists(listing1) {
+				_ = os.Rename(listing1, listing1+"-err")
+			}
+			if bilib.FileExists(listing2) {
+				_ = os.Rename(listing2, listing2+"-err")
+			}
+			fs.Errorf(nil, "Bisync critical error: %v", err)
+			fs.Errorf(nil, "Bisync aborted. Must run --resync to recover.")
 		}
-		fs.Errorf(nil, "Bisync critical error: %v", err)
-		fs.Errorf(nil, "Bisync aborted. Must run --resync to recover.")
 		return ErrBisyncAborted
 	}
 	if b.abort {
@@ -152,6 +158,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 		fs.Infof(nil, "Validating listings for Path1 %s vs Path2 %s", quotePath(path1), quotePath(path2))
 		if err = b.checkSync(listing1, listing2); err != nil {
 			b.critical = true
+			b.retryable = true
 		}
 		return err
 	}
@@ -176,6 +183,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 	var fctx context.Context
 	if fctx, err = b.opt.applyFilters(octx); err != nil {
 		b.critical = true
+		b.retryable = true
 		return
 	}
 
@@ -188,6 +196,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 	if !bilib.FileExists(listing1) || !bilib.FileExists(listing2) {
 		// On prior critical error abort, the prior listings are renamed to .lst-err to lock out further runs
 		b.critical = true
+		b.retryable = true
 		return errors.New("cannot find prior Path1 or Path2 listings, likely due to critical error on prior run")
 	}
 
@@ -215,6 +224,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 		err = b.checkAccess(ds1.checkFiles, ds2.checkFiles)
 		if err != nil {
 			b.critical = true
+			b.retryable = true
 			return
 		}
 	}
@@ -255,6 +265,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 		changes1, changes2, err = b.applyDeltas(octx, ds1, ds2)
 		if err != nil {
 			b.critical = true
+			// b.retryable = true // not sure about this one
 			return err
 		}
 	}
@@ -283,6 +294,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 	}
 	if err != nil {
 		b.critical = true
+		b.retryable = true
 		return err
 	}
 
@@ -310,6 +322,7 @@ func (b *bisyncRun) runLocked(octx context.Context, listing1, listing2 string) (
 		}
 		if err != nil {
 			b.critical = true
+			b.retryable = true
 			return err
 		}
 	}
@@ -341,6 +354,39 @@ func (b *bisyncRun) resync(octx, fctx context.Context, listing1, listing2 string
 		return err
 	}
 
+	// Check access health on the Path1 and Path2 filesystems
+	// enforce even though this is --resync
+	if b.opt.CheckAccess {
+		fs.Infof(nil, "Checking access health")
+
+		ds1 := &deltaSet{
+			checkFiles: bilib.Names{},
+		}
+
+		ds2 := &deltaSet{
+			checkFiles: bilib.Names{},
+		}
+
+		for _, file := range filesNow1.list {
+			if filepath.Base(file) == b.opt.CheckFilename {
+				ds1.checkFiles.Add(file)
+			}
+		}
+
+		for _, file := range filesNow2.list {
+			if filepath.Base(file) == b.opt.CheckFilename {
+				ds2.checkFiles.Add(file)
+			}
+		}
+
+		err = b.checkAccess(ds1.checkFiles, ds2.checkFiles)
+		if err != nil {
+			b.critical = true
+			b.retryable = true
+			return err
+		}
+	}
+
 	copy2to1 := []string{}
 	for _, file := range filesNow2.list {
 		if !filesNow1.has(file) {
@@ -367,11 +413,34 @@ func (b *bisyncRun) resync(octx, fctx context.Context, listing1, listing2 string
 		// prevent overwriting Google Doc files (their size is -1)
 		filterSync.Opt.MinSize = 0
 	}
-	if err = sync.Sync(ctxSync, b.fs2, b.fs1, false); err != nil {
+	if err = sync.CopyDir(ctxSync, b.fs2, b.fs1, b.opt.CreateEmptySrcDirs); err != nil {
 		b.critical = true
 		return err
 	}
 
+	if b.opt.CreateEmptySrcDirs {
+		// copy Path2 back to Path1, for empty dirs
+		// the fastCopy above cannot include directories, because it relies on --files-from for filtering,
+		// so instead we'll copy them here, relying on fctx for our filtering.
+
+		// This preserves the original resync order for backward compatibility. It is essentially:
+		// rclone copy Path2 Path1 --ignore-existing
+		// rclone copy Path1 Path2 --create-empty-src-dirs
+		// rclone copy Path2 Path1 --create-empty-src-dirs
+
+		// although if we were starting from scratch, it might be cleaner and faster to just do:
+		// rclone copy Path2 Path1 --create-empty-src-dirs
+		// rclone copy Path1 Path2 --create-empty-src-dirs
+
+		fs.Infof(nil, "Resynching Path2 to Path1 (for empty dirs)")
+
+		//note copy (not sync) and dst comes before src
+		if err = sync.CopyDir(ctxSync, b.fs1, b.fs2, b.opt.CreateEmptySrcDirs); err != nil {
+			b.critical = true
+			return err
+		}
+	}
+
 	fs.Infof(nil, "Resync updating listings")
 	if _, err = b.makeListing(fctx, b.fs1, listing1); err != nil {
 		b.critical = true
diff --git a/cmd/bisync/queue.go b/cmd/bisync/queue.go
index 08bb20862..701c8a922 100644
--- a/cmd/bisync/queue.go
+++ b/cmd/bisync/queue.go
@@ -3,6 +3,7 @@ package bisync
 import (
 	"context"
 	"fmt"
+	"sort"
 
 	"github.com/rclone/rclone/cmd/bisync/bilib"
 	"github.com/rclone/rclone/fs"
@@ -23,7 +24,7 @@ func (b *bisyncRun) fastCopy(ctx context.Context, fsrc, fdst fs.Fs, files bilib.
 		}
 	}
 
-	return sync.CopyDir(ctxCopy, fdst, fsrc, false)
+	return sync.CopyDir(ctxCopy, fdst, fsrc, b.opt.CreateEmptySrcDirs)
 }
 
 func (b *bisyncRun) fastDelete(ctx context.Context, f fs.Fs, files bilib.Names, queueName string) error {
@@ -32,7 +33,14 @@ func (b *bisyncRun) fastDelete(ctx context.Context, f fs.Fs, files bilib.Names,
 	}
 
 	transfers := fs.GetConfig(ctx).Transfers
-	ctxRun := b.opt.setDryRun(ctx)
+
+	ctxRun, filterDelete := filter.AddConfig(b.opt.setDryRun(ctx))
+
+	for _, file := range files.ToList() {
+		if err := filterDelete.AddFile(file); err != nil {
+			return err
+		}
+	}
 
 	objChan := make(fs.ObjectsChan, transfers)
 	errChan := make(chan error, 1)
@@ -53,6 +61,36 @@ func (b *bisyncRun) fastDelete(ctx context.Context, f fs.Fs, files bilib.Names,
 	return err
 }
 
+// operation should be "make" or "remove"
+func (b *bisyncRun) syncEmptyDirs(ctx context.Context, dst fs.Fs, candidates bilib.Names, dirsList *fileList, operation string) {
+	if b.opt.CreateEmptySrcDirs && (!b.opt.Resync || operation == "make") {
+
+		candidatesList := candidates.ToList()
+		if operation == "remove" {
+			// reverse the sort order to ensure we remove subdirs before parent dirs
+			sort.Sort(sort.Reverse(sort.StringSlice(candidatesList)))
+		}
+
+		for _, s := range candidatesList {
+			var direrr error
+			if dirsList.has(s) { //make sure it's a dir, not a file
+				if operation == "remove" {
+					//note: we need to use Rmdirs instead of Rmdir because directories will fail to delete if they have other empty dirs inside of them.
+					direrr = operations.Rmdirs(ctx, dst, s, false)
+				} else if operation == "make" {
+					direrr = operations.Mkdir(ctx, dst, s)
+				} else {
+					direrr = fmt.Errorf("invalid operation. Expected 'make' or 'remove', received '%q'", operation)
+				}
+
+				if direrr != nil {
+					fs.Debugf(nil, "Error syncing directory: %v", direrr)
+				}
+			}
+		}
+	}
+}
+
 func (b *bisyncRun) saveQueue(files bilib.Names, jobName string) error {
 	if !b.opt.SaveQueues {
 		return nil
diff --git a/docs/content/bisync.md b/docs/content/bisync.md
index f8d0c440f..e50d8cda2 100644
--- a/docs/content/bisync.md
+++ b/docs/content/bisync.md
@@ -91,10 +91,16 @@ Optional Flags:
                                 If exceeded, the bisync run will abort. (default: 50%)
       --force                   Bypass `--max-delete` safety check and run the sync.
                                 Consider using with `--verbose`
+      --create-empty-src-dirs   Sync creation and deletion of empty directories. 
+                                  (Not compatible with --remove-empty-dirs)
       --remove-empty-dirs       Remove empty directories at the final cleanup step.
   -1, --resync                  Performs the resync run.
                                 Warning: Path1 files may overwrite Path2 versions.
                                 Consider using `--verbose` or `--dry-run` first.
+      --ignore-listing-checksum Do not use checksums for listings 
+                                  (add --ignore-checksum to additionally skip post-copy checksum checks)
+      --resilient               Allow future runs to retry after certain less-serious errors, 
+                                  instead of requiring --resync. Use at your own risk!
       --localtime               Use local time in listings (default: UTC)
       --no-cleanup              Retain working files (useful for troubleshooting and testing).
       --workdir PATH            Use custom working directory (useful for testing).
@@ -121,7 +127,7 @@ Cloud references are distinguished by having a `:` in the argument
 (see [Windows support](#windows) below).
 
 Path1 and Path2 are treated equally, in that neither has priority for
-file changes, and access efficiency does not change whether a remote
+file changes (except during [`--resync`](#resync)), and access efficiency does not change whether a remote
 is on Path1 or Path2.
 
 The listings in bisync working directory (default: `~/.cache/rclone/bisync`)
@@ -130,8 +136,8 @@ to individual directories within the tree may be set up, e.g.:
 `path_to_local_tree..dropbox_subdir.lst`.
 
 Any empty directories after the sync on both the Path1 and Path2
-filesystems are not deleted by default. If the `--remove-empty-dirs`
-flag is specified, then both paths will have any empty directories purged
+filesystems are not deleted by default, unless `--create-empty-src-dirs` is specified. 
+If the `--remove-empty-dirs` flag is specified, then both paths will have ALL empty directories purged
 as the last step in the process.
 
 ## Command-line flags
@@ -140,15 +146,31 @@ as the last step in the process.
 
 This will effectively make both Path1 and Path2 filesystems contain a
 matching superset of all files. Path2 files that do not exist in Path1 will
-be copied to Path1, and the process will then sync the Path1 tree to Path2.
+be copied to Path1, and the process will then copy the Path1 tree to Path2.
 
-The base directories on the both Path1 and Path2 filesystems must exist
+The `--resync` sequence is roughly equivalent to:
+```
+rclone copy Path2 Path1 --ignore-existing
+rclone copy Path1 Path2
+```
+Or, if using `--create-empty-src-dirs`:
+```
+rclone copy Path2 Path1 --ignore-existing
+rclone copy Path1 Path2 --create-empty-src-dirs
+rclone copy Path2 Path1 --create-empty-src-dirs
+```
+
+The base directories on both Path1 and Path2 filesystems must exist
 or bisync will fail. This is required for safety - that bisync can verify
 that both paths are valid.
 
-When using `--resync`, a newer version of a file either on Path1 or Path2
-filesystem, will overwrite the file on the other path (only the last version
-will be kept). Carefully evaluate deltas using [--dry-run](/flags/#non-backend-flags).
+When using `--resync`, a newer version of a file on the Path2 filesystem
+will be overwritten by the Path1 filesystem version. 
+(Note that this is [NOT entirely symmetrical](https://github.com/rclone/rclone/issues/5681#issuecomment-938761815).)
+Carefully evaluate deltas using [--dry-run](/flags/#non-backend-flags).
+
+[//]: # (I reverted a recent change in the above paragraph, as it was incorrect.
+https://github.com/rclone/rclone/commit/dd72aff98a46c6e20848ac7ae5f7b19d45802493 )
 
 For a resync run, one of the paths may be empty (no files in the path tree).
 The resync run should result in files on both paths, else a normal non-resync
@@ -165,13 +187,23 @@ Access check files are an additional safety measure against data loss.
 bisync will ensure it can find matching `RCLONE_TEST` files in the same places
 in the Path1 and Path2 filesystems.
 `RCLONE_TEST` files are not generated automatically.
-For `--check-access`to succeed, you must first either:
-**A)** Place one or more `RCLONE_TEST` files in the Path1 or Path2 filesystem
-and then do either a run without `--check-access` or a [--resync](#resync) to
-set matching files on both filesystems, or
+For `--check-access` to succeed, you must first either:
+**A)** Place one or more `RCLONE_TEST` files in both systems, or
 **B)** Set `--check-filename` to a filename already in use in various locations
-throughout your sync'd fileset.
-Time stamps and file contents are not important, just the names and locations.
+throughout your sync'd fileset. Recommended methods for **A)** include:
+* `rclone touch Path1/RCLONE_TEST` (create a new file)
+* `rclone copyto Path1/RCLONE_TEST Path2/RCLONE_TEST` (copy an existing file)
+* `rclone copy Path1/RCLONE_TEST Path2/RCLONE_TEST  --include "RCLONE_TEST"` (copy multiple files at once, recursively)
+* create the files manually (outside of rclone)
+* run `bisync` once *without* `--check-access` to set matching files on both filesystems
+will also work, but is not preferred, due to potential for user error 
+(you are temporarily disabling the safety feature).
+
+Note that `--check-access` is still enforced on `--resync`, so `bisync --resync --check-access` 
+will not work as a method of initially setting the files (this is to ensure that bisync can't 
+[inadvertently circumvent its own safety switch](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=3.%20%2D%2Dcheck%2Daccess%20doesn%27t%20always%20fail%20when%20it%20should).)
+
+Time stamps and file contents for `RCLONE_TEST` files are not important, just the names and locations.
 If you have symbolic links in your sync tree it is recommended to place
 `RCLONE_TEST` files in the linked-to directory tree to protect against
 bisync assuming a bunch of deleted files if the linked-to tree should not be
@@ -198,7 +230,7 @@ files and a bunch of new files.
 This safety check is intended to block bisync from deleting all of the
 files on both filesystems due to a temporary network access issue, or if
 the user had inadvertently deleted the files on one side or the other.
-To force the sync either set a different delete percentage limit,
+To force the sync, either set a different delete percentage limit,
 e.g. `--max-delete 75` (allows up to 75% deletion), or use `--force`
 to bypass the check.
 
@@ -215,17 +247,17 @@ An [example filters file](#example-filters-file) contains filters for
 non-allowed files for synching with Dropbox.
 
 If you make changes to your filters file then bisync requires a run
-with `--resync`. This is a safety feature, which avoids existing files
+with `--resync`. This is a safety feature, which prevents existing files
 on the Path1 and/or Path2 side from seeming to disappear from view
 (since they are excluded in the new listings), which would fool bisync
 into seeing them as deleted (as compared to the prior run listings),
 and then bisync would proceed to delete them for real.
 
-To block this from happening bisync calculates an MD5 hash of the filters file
+To block this from happening, bisync calculates an MD5 hash of the filters file
 and stores the hash in a `.md5` file in the same place as your filters file.
-On the next runs with `--filters-file` set, bisync re-calculates the MD5 hash
-of the current filters file and compares it to the hash stored in `.md5` file.
-If they don't match the run aborts with a critical error and thus forces you
+On the next run with `--filters-file` set, bisync re-calculates the MD5 hash
+of the current filters file and compares it to the hash stored in the `.md5` file.
+If they don't match, the run aborts with a critical error and thus forces you
 to do a `--resync`, likely avoiding a disaster.
 
 #### --check-sync
@@ -245,6 +277,60 @@ sync run times for very large numbers of files.
 The check may be run manually with `--check-sync=only`. It runs only the
 integrity check and terminates without actually synching.
 
+See also: [Concurrent modifications](#concurrent-modifications)
+
+
+#### --ignore-listing-checksum
+
+By default, bisync will retrieve (or generate) checksums (for backends that support them) 
+when creating the listings for both paths, and store the checksums in the listing files. 
+`--ignore-listing-checksum` will disable this behavior, which may speed things up considerably, 
+especially on backends (such as [local](/local/)) where hashes must be computed on the fly instead of retrieved. 
+Please note the following:
+
+* While checksums are (by default) generated and stored in the listing files, 
+they are NOT currently used for determining diffs (deltas). 
+It is anticipated that full checksum support will be added in a future version.
+* `--ignore-listing-checksum` is NOT the same as [`--ignore-checksum`](/docs/#ignore-checksum), 
+and you may wish to use one or the other, or both. In a nutshell: 
+`--ignore-listing-checksum` controls whether checksums are considered when scanning for diffs, 
+while `--ignore-checksum` controls whether checksums are considered during the copy/sync operations that follow, 
+if there ARE diffs.
+* Unless `--ignore-listing-checksum` is passed, bisync currently computes hashes for one path 
+*even when there's no common hash with the other path* 
+(for example, a [crypt](/crypt/#modified-time-and-hashes) remote.)
+* If both paths support checksums and have a common hash, 
+AND `--ignore-listing-checksum` was not specified when creating the listings, 
+`--check-sync=only` can be used to compare Path1 vs. Path2 checksums (as of the time the previous listings were created.) 
+However, `--check-sync=only` will NOT include checksums if the previous listings 
+were generated on a run using `--ignore-listing-checksum`. For a more robust integrity check of the current state, 
+consider using [`check`](commands/rclone_check/) 
+(or [`cryptcheck`](/commands/rclone_cryptcheck/), if at least one path is a `crypt` remote.)
+
+#### --resilient
+
+***Caution: this is an experimental feature. Use at your own risk!***
+
+By default, most errors or interruptions will cause bisync to abort and 
+require [`--resync`](#resync) to recover. This is a safety feature, 
+to prevent bisync from running again until a user checks things out. 
+However, in some cases, bisync can go too far and enforce a lockout when one isn't actually necessary, 
+like for certain less-serious errors that might resolve themselves on the next run. 
+When `--resilient` is specified, bisync tries its best to recover and self-correct, 
+and only requires `--resync` as a last resort when a human's involvement is absolutely necessary. 
+The intended use case is for running bisync as a background process (such as via scheduled [cron](#cron)).
+
+When using `--resilient` mode, bisync will still report the error and abort, 
+however it will not lock out future runs -- allowing the possibility of retrying at the next normally scheduled time, 
+without requiring a `--resync` first. Examples of such retryable errors include 
+access test failures, missing listing files, and filter change detections. 
+These safety features will still prevent the *current* run from proceeding -- 
+the difference is that if conditions have improved by the time of the *next* run, 
+that next run will be allowed to proceed. 
+Certain more serious errors will still enforce a `--resync` lockout, even in `--resilient` mode, to prevent data loss.
+
+Behavior of `--resilient` may change in a future version.
+
 ## Operation
 
 ### Runtime flow details
@@ -288,15 +374,26 @@ Path1 deleted | File no longer exists on Path1                | File is deleted
 
  Type                           | Description                           | Result                             | Implementation
 --------------------------------|---------------------------------------|------------------------------------|-----------------------
-Path1 new AND Path2 new         | File is new on Path1 AND new on Path2 | Files renamed to _Path1 and _Path2 | `rclone copy` _Path2 file to Path1, `rclone copy` _Path1 file to Path2
-Path2 newer AND Path1 changed   | File is newer on Path2 AND also changed (newer/older/size) on Path1 | Files renamed to _Path1 and _Path2 | `rclone copy` _Path2 file to Path1, `rclone copy` _Path1 file to Path2
+Path1 new/changed AND Path2 new/changed AND Path1 == Path2       | File is new/changed on Path1 AND new/changed on Path2 AND Path1 version is currently identical to Path2 | No change | None
+Path1 new AND Path2 new         | File is new on Path1 AND new on Path2 (and Path1 version is NOT identical to Path2) | Files renamed to _Path1 and _Path2 | `rclone copy` _Path2 file to Path1, `rclone copy` _Path1 file to Path2
+Path2 newer AND Path1 changed   | File is newer on Path2 AND also changed (newer/older/size) on Path1 (and Path1 version is NOT identical to Path2) | Files renamed to _Path1 and _Path2 | `rclone copy` _Path2 file to Path1, `rclone copy` _Path1 file to Path2
 Path2 newer AND Path1 deleted   | File is newer on Path2 AND also deleted on Path1 | Path2 version survives  | `rclone copy` Path2 to Path1
 Path2 deleted AND Path1 changed | File is deleted on Path2 AND changed (newer/older/size) on Path1 | Path1 version survives |`rclone copy` Path1 to Path2
 Path1 deleted AND Path2 changed | File is deleted on Path1 AND changed (newer/older/size) on Path2 | Path2 version survives  | `rclone copy` Path2 to Path1
 
+As of `rclone v1.64`, bisync is now better at detecting *false positive* sync conflicts, 
+which would previously have resulted in unnecessary renames and duplicates. 
+Now, when bisync comes to a file that it wants to rename (because it is new/changed on both sides), 
+it first checks whether the Path1 and Path2 versions are currently *identical* 
+(using the same underlying function as [`check`](commands/rclone_check/).) 
+If bisync concludes that the files are identical, it will skip them and move on. 
+Otherwise, it will create renamed `..Path1` and `..Path2` duplicates, as before. 
+This behavior also [improves the experience of renaming directories](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=Renamed%20directories), 
+as a `--resync` is no longer required, so long as the same change has been made on both sides.
+
 ### All files changed check {#all-files-changed}
 
-if _all_ prior existing files on either of the filesystems have changed
+If _all_ prior existing files on either of the filesystems have changed
 (e.g. timestamps have changed due to changing the system's timezone)
 then bisync will abort without making any changes.
 Any new files are not considered for this check. You could use `--force`
@@ -333,7 +430,7 @@ It is recommended to use `--resync --dry-run --verbose` initially and
 _carefully_ review what changes will be made before running the `--resync`
 without `--dry-run`.
 
-Most of these events come up due to a error status from an internal call.
+Most of these events come up due to an error status from an internal call.
 On such a critical error the `{...}.path1.lst` and `{...}.path2.lst`
 listing files are renamed to extension `.lst-err`, which blocks any future
 bisync runs (since the normal `.lst` files are not found).
@@ -343,6 +440,8 @@ typically at `${HOME}/.cache/rclone/bisync/` on Linux.
 Some errors are considered temporary and re-running the bisync is not blocked.
 The _critical return_ blocks further bisync runs.
 
+See also: [`--resilient`](#resilient)
+
 ### Lock file
 
 When bisync is running, a lock file is created in the bisync working directory,
@@ -383,7 +482,7 @@ It has not been fully tested with other services yet.
 If it works, or sorta works, please let us know and we'll update the list.
 Run the test suite to check for proper operation as described below.
 
-First release of `rclone bisync` requires that underlying backend supported
+First release of `rclone bisync` requires that underlying backend supports
 the modification time feature and will refuse to run otherwise.
 This limitation will be lifted in a future `rclone bisync` release.
 
@@ -398,38 +497,97 @@ This will be solved in a future release, there is no workaround at the moment.
 Files that **change during** a bisync run may result in data loss.
 This has been seen in a highly dynamic environment, where the filesystem
 is getting hammered by running processes during the sync.
-The solution is to sync at quiet times or [filter out](#filtering)
+The currently recommended solution is to sync at quiet times or [filter out](#filtering)
 unnecessary directories and files.
 
-### Empty directories
+As an [alternative approach](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=scans%2C%20to%20avoid-,errors%20if%20files%20changed%20during%20sync,-Given%20the%20number), 
+consider using `--check-sync=false` (and possibly `--resilient`) to make bisync more forgiving
+of filesystems that change during the sync. 
+Be advised that this may cause bisync to miss events that occur during a bisync run, 
+so it is a good idea to supplement this with a periodic independent integrity check, 
+and corrective sync if diffs are found. For example, a possible sequence could look like this:
+
+1. Normally scheduled bisync run:
+
+```
+rclone bisync Path1 Path2 -MPc --check-access --max-delete 10 --filters-file /path/to/filters.txt -v --check-sync=false --no-cleanup --ignore-listing-checksum --disable ListR --checkers=16 --drive-pacer-min-sleep=10ms --create-empty-src-dirs --resilient
+```
+
+2. Periodic independent integrity check (perhaps scheduled nightly or weekly):
+
+```
+rclone check -MvPc Path1 Path2 --filter-from /path/to/filters.txt
+```
 
-New empty directories on one path are _not_ propagated to the other side.
-This is because bisync (and rclone) natively works on files not directories.
-The following sequence is a workaround but will not propagate the delete
-of an empty directory to the other side:
+3. If diffs are found, you have some choices to correct them. 
+If one side is more up-to-date and you want to make the other side match it, you could run:
 
 ```
-rclone bisync PATH1 PATH2
-rclone copy PATH1 PATH2 --filter "+ */" --filter "- **" --create-empty-src-dirs
-rclone copy PATH2 PATH2 --filter "+ */" --filter "- **" --create-empty-src-dirs
+rclone sync Path1 Path2 --filter-from /path/to/filters.txt --create-empty-src-dirs -MPc -v  
 ```
+(or switch Path1 and Path2 to make Path2 the source-of-truth)
+
+Or, if neither side is totally up-to-date, you could run a `--resync` to bring them back into agreement 
+(but remember that this could cause deleted files to re-appear.)
+
+*Note also that `rclone check` does not currently include empty directories, 
+so if you want to know if any empty directories are out of sync, 
+consider alternatively running the above `rclone sync` command with `--dry-run` added.
+
+### Empty directories
+
+By default, new/deleted empty directories on one path are _not_ propagated to the other side.
+This is because bisync (and rclone) natively works on files, not directories.
+However, this can be changed with the `--create-empty-src-dirs` flag, which works in
+much the same way as in [`sync`](/commands/rclone_sync/) and [`copy`](/commands/rclone_copy/).
+When used, empty directories created or deleted on one side will also be created or deleted on the other side.
+The following should be noted:
+* `--create-empty-src-dirs` is not compatible with `--remove-empty-dirs`. Use only one or the other (or neither).
+* It is not recommended to switch back and forth between `--create-empty-src-dirs` 
+and the default (no `--create-empty-src-dirs`) without running `--resync`. 
+This is because it may appear as though all directories (not just the empty ones) were created/deleted,
+when actually you've just toggled between making them visible/invisible to bisync. 
+It looks scarier than it is, but it's still probably best to stick to one or the other, 
+and use `--resync` when you need to switch.
 
 ### Renamed directories
 
-Renaming a folder on the Path1 side results is deleting all files on
+Renaming a folder on the Path1 side results in deleting all files on
 the Path2 side and then copying all files again from Path1 to Path2.
 Bisync sees this as all files in the old directory name as deleted and all
-files in the new directory name as new. Similarly, renaming a directory on
-both sides to the same name will result in creating `..path1` and `..path2`
-files on both sides.
-Currently the most effective and efficient method of renaming a directory
-is to rename it on both sides, then do a `--resync`.
+files in the new directory name as new. 
+Currently, the most effective and efficient method of renaming a directory
+is to rename it to the same name on both sides. (As of `rclone v1.64`, 
+a `--resync` is no longer required after doing so, as bisync will automatically
+detect that Path1 and Path2 are in agreement.)
+
+### `--fast-list` used by default
+
+Unlike most other rclone commands, bisync uses [`--fast-list`](/docs/#fast-list) by default, 
+for backends that support it. In many cases this is desirable, however, 
+there are some scenarios in which bisync could be faster *without* `--fast-list`, 
+and there is also a [known issue concerning Google Drive users with many empty directories](https://github.com/rclone/rclone/commit/cbf3d4356135814921382dd3285d859d15d0aa77). 
+For now, the recommended way to avoid using `--fast-list` is to add `--disable ListR` 
+to all bisync commands. The default behavior may change in a future version.
+
+### Overridden Configs
+
+When rclone detects an overridden config, it adds a suffix like `{ABCDE}` on the fly 
+to the internal name of the remote. Bisync follows suit by including this suffix in its listing filenames. 
+However, this suffix does not necessarily persist from run to run, especially if different flags are provided. 
+So if next time the suffix assigned is `{FGHIJ}`, bisync will get confused, 
+because it's looking for a listing file with `{FGHIJ}`, when the file it wants has `{ABCDE}`. 
+As a result, it throws 
+`Bisync critical error: cannot find prior Path1 or Path2 listings, likely due to critical error on prior run` 
+and refuses to run again until the user runs a `--resync` (unless using `--resilient`). 
+The best workaround at the moment is to set any backend-specific flags in the [config file](/commands/rclone_config/) 
+instead of specifying them with command flags. (You can still override them as needed for other rclone commands.)
 
 ### Case sensitivity
 
 Synching with **case-insensitive** filesystems, such as Windows or `Box`,
 can result in file name conflicts. This will be fixed in a future release.
-The near term workaround is to make sure that files on both sides
+The near-term workaround is to make sure that files on both sides
 don't have spelling case differences (`Smile.jpg` vs. `smile.jpg`).
 
 ## Windows support {#windows}
@@ -492,7 +650,7 @@ below.
     - Excluding such dirs first will make rclone operations (much) faster.
     - Specific files may also be excluded, as with the Dropbox exclusions
       example below.
-2. Decide if its easier (or cleaner) to:
+2. Decide if it's easier (or cleaner) to:
     - Include select directories and therefore _exclude everything else_ -- or --
     - Exclude select directories and therefore _include everything else_
 3. Include select directories:
@@ -511,7 +669,7 @@ below.
       For example: `-/Desktop/tempfiles/`, or `- /testdir/`.
       Again, a `**` on the end is not necessary.
     - Do _not_ add a `- **` in the file. Without this line, everything
-      will be included that has not be explicitly excluded.
+      will be included that has not been explicitly excluded.
     - Disregard step 3.
 
 A few rules for the syntax of a filter file expanding on
@@ -655,7 +813,7 @@ The second has no deltas between local and remote.
 The `--dry-run` messages may indicate that it would try to delete some files.
 For example, if a file is new on Path2 and does not exist on Path1 then
 it would normally be copied to Path1, but with `--dry-run` enabled those
-copies don't happen, which leads to the attempted delete on the Path2,
+copies don't happen, which leads to the attempted delete on Path2,
 blocked again by --dry-run: `... Not deleting as --dry-run`.
 
 This whole confusing situation is an artifact of the `--dry-run` flag.
@@ -664,14 +822,14 @@ copied to Path1 then the threatened deletes on Path2 may be disregarded.
 
 ### Retries
 
-Rclone has built in retries. If you run with `--verbose` you'll see
+Rclone has built-in retries. If you run with `--verbose` you'll see
 error and retry messages such as shown below. This is usually not a bug.
-If at the end of the run you see `Bisync successful` and not
+If at the end of the run, you see `Bisync successful` and not
 `Bisync critical error` or `Bisync aborted` then the run was successful,
 and you can ignore the error messages.
 
 The following run shows an intermittent fail. Lines _5_ and _6- are
-low level messages. Line _6_ is a bubbled-up _warning_ message, conveying
+low-level messages. Line _6_ is a bubbled-up _warning_ message, conveying
 the error. Rclone normally retries failing commands, so there may be
 numerous such messages in the log.
 
@@ -751,7 +909,7 @@ and an OwnCloud server, with output logged to a runlog file:
   */5  *    *    *    *   /path/to/rclone bisync /local/files MyCloud: --check-access --filters-file /path/to/bysync-filters.txt --log-file /path/to//bisync.log
 ```
 
-See [crontab syntax](https://www.man7.org/linux/man-pages/man1/crontab.1p.html#INPUT_FILES)).
+See [crontab syntax](https://www.man7.org/linux/man-pages/man1/crontab.1p.html#INPUT_FILES)
 for the details of crontab time interval expressions.
 
 If you run `rclone bisync` as a cron job, redirect stdout/stderr to a file.
@@ -943,7 +1101,7 @@ test command flags can be equally prefixed by a single `-` or double dash.
   synched tree even if there are check file mismatches in the test tree.
 - Some Dropbox tests can fail, notably printing the following message:
   `src and dst identical but can't set mod time without deleting and re-uploading`
-  This is expected and happens due a way Dropbox handles modification times.
+  This is expected and happens due to the way Dropbox handles modification times.
   You should use the `-refresh-times` test flag to make up for this.
 - If Dropbox tests hit request limit for you and print error message
   `too_many_requests/...: Too many requests or write operations.`
@@ -1096,9 +1254,28 @@ with [@cjnaz](https://github.com/cjnaz)'s full support and encouragement.
 
 Bisync adopts the differential synchronization technique, which is
 based on keeping history of changes performed by both synchronizing sides.
-See the _Dual Shadow Method_ section in the
+See the _Dual Shadow Method_ section in
 [Neil Fraser's article](https://neil.fraser.name/writing/sync/).
 
 Also note a number of academic publications by
 [Benjamin Pierce](http://www.cis.upenn.edu/%7Ebcpierce/papers/index.shtml#File%20Synchronization)
 about _Unison_ and synchronization in general.
+
+## Changelog
+
+### `v1.64`
+* Fixed an [issue](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=1.%20Dry%20runs%20are%20not%20completely%20dry) 
+causing dry runs to inadvertently commit filter changes
+* Fixed an [issue](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=2.%20%2D%2Dresync%20deletes%20data%2C%20contrary%20to%20docs) 
+causing `--resync` to erroneously delete empty folders and duplicate files unique to Path2
+* `--check-access` is now enforced during `--resync`, preventing data loss in [certain user error scenarios](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=%2D%2Dcheck%2Daccess%20doesn%27t%20always%20fail%20when%20it%20should)
+* Fixed an [issue](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=5.%20Bisync%20reads%20files%20in%20excluded%20directories%20during%20delete%20operations) 
+causing bisync to consider more files than necessary due to overbroad filters during delete operations
+* [Improved detection of false positive change conflicts](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=1.%20Identical%20files%20should%20be%20left%20alone%2C%20even%20if%20new/newer/changed%20on%20both%20sides) 
+(identical files are now left alone instead of renamed)
+* Added [support for `--create-empty-src-dirs`](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=3.%20Bisync%20should%20create/delete%20empty%20directories%20as%20sync%20does%2C%20when%20%2D%2Dcreate%2Dempty%2Dsrc%2Ddirs%20is%20passed)
+* Added experimental `--resilient` mode to allow [recovery from self-correctable errors](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=2.%20Bisync%20should%20be%20more%20resilient%20to%20self%2Dcorrectable%20errors)
+* Added [new `--ignore-listing-checksum` flag](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=6.%20%2D%2Dignore%2Dchecksum%20should%20be%20split%20into%20two%20flags%20for%20separate%20purposes) 
+to distinguish from `--ignore-checksum`
+* [Performance improvements](https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=6.%20Deletes%20take%20several%20times%20longer%20than%20copies) for large remotes
+* Documentation and testing improvements
\ No newline at end of file
diff --git a/fs/config.go b/fs/config.go
index 060567447..c0bd306f8 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -145,8 +145,9 @@ type ConfigInfo struct {
 	Metadata                   bool
 	ServerSideAcrossConfigs    bool
 	TerminalColorMode          TerminalColorMode
-	DefaultTime                Time // time that directories with no time should display
-	Inplace                    bool // Download directly to destination file instead of atomic download to temp/rename
+	DefaultTime                Time       // time that directories with no time should display
+	Inplace                    bool       // Download directly to destination file instead of atomic download to temp/rename
+	MultiThreadChunkSize       SizeSuffix // Chunk size for multi-thread downloads / uploads, if not set by filesystem
 }
 
 // NewConfig creates a new config with everything set to the default
@@ -184,6 +185,7 @@ func NewConfig() *ConfigInfo {
 	//	c.StatsOneLineDateFormat = "2006/01/02 15:04:05 - "
 	c.MultiThreadCutoff = SizeSuffix(250 * 1024 * 1024)
 	c.MultiThreadStreams = 4
+	c.MultiThreadChunkSize = SizeSuffix(50 * 1024 * 1024)
 
 	c.TrackRenamesStrategy = "hash"
 	c.FsCacheExpireDuration = 300 * time.Second
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 3839691d0..36ff2cb6f 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -127,6 +127,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.FVarP(flagSet, &ci.MultiThreadCutoff, "multi-thread-cutoff", "", "Use multi-thread downloads for files above this size", "Copy")
 	flags.IntVarP(flagSet, &ci.MultiThreadStreams, "multi-thread-streams", "", ci.MultiThreadStreams, "Max number of streams to use for multi-thread downloads", "Copy")
 	flags.FVarP(flagSet, &ci.MultiThreadWriteBufferSize, "multi-thread-write-buffer-size", "", "In memory buffer size for writing when in multi-thread mode", "Copy")
+	flags.FVarP(flagSet, &ci.MultiThreadChunkSize, "multi-thread-chunk-size", "", "Chunk size for multi-thread downloads / uploads, if not set by filesystem", "Copy")
 	flags.BoolVarP(flagSet, &ci.UseJSONLog, "use-json-log", "", ci.UseJSONLog, "Use json log format", "Logging")
 	flags.StringVarP(flagSet, &ci.OrderBy, "order-by", "", ci.OrderBy, "Instructions on how to order the transfers, e.g. 'size,descending'", "Copy")
 	flags.StringArrayVarP(flagSet, &uploadHeaders, "header-upload", "", nil, "Set HTTP header for upload transactions", "Networking")
diff --git a/fs/features.go b/fs/features.go
index 4c3612285..d722b4412 100644
--- a/fs/features.go
+++ b/fs/features.go
@@ -150,6 +150,13 @@ type Features struct {
 	// It truncates any existing object
 	OpenWriterAt func(ctx context.Context, remote string, size int64) (WriterAtCloser, error)
 
+	// OpenChunkWriter returns the chunk size and a ChunkWriter
+	//
+	// Pass in the remote and the src object
+	// You can also use options to hint at the desired chunk size
+	//
+	OpenChunkWriter func(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (chunkSize int64, writer ChunkWriter, err error)
+
 	// UserInfo returns info about the connected user
 	UserInfo func(ctx context.Context) (map[string]string, error)
 
@@ -301,6 +308,9 @@ func (ft *Features) Fill(ctx context.Context, f Fs) *Features {
 	if do, ok := f.(OpenWriterAter); ok {
 		ft.OpenWriterAt = do.OpenWriterAt
 	}
+	if do, ok := f.(OpenChunkWriter); ok {
+		ft.OpenChunkWriter = do.OpenChunkWriter
+	}
 	if do, ok := f.(UserInfoer); ok {
 		ft.UserInfo = do.UserInfo
 	}
@@ -393,6 +403,9 @@ func (ft *Features) Mask(ctx context.Context, f Fs) *Features {
 	if mask.OpenWriterAt == nil {
 		ft.OpenWriterAt = nil
 	}
+	if mask.OpenChunkWriter == nil {
+		ft.OpenChunkWriter = nil
+	}
 	if mask.UserInfo == nil {
 		ft.UserInfo = nil
 	}
@@ -623,6 +636,25 @@ type OpenWriterAter interface {
 	OpenWriterAt(ctx context.Context, remote string, size int64) (WriterAtCloser, error)
 }
 
+type OpenChunkWriter interface {
+	// OpenChunkWriter returns the chunk size and a ChunkWriter
+	//
+	// Pass in the remote and the src object
+	// You can also use options to hint at the desired chunk size
+	OpenChunkWriter(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (chunkSize int64, writer ChunkWriter, err error)
+}
+
+type ChunkWriter interface {
+	// WriteChunk will write chunk number with reader bytes, where chunk number >= 0
+	WriteChunk(chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error)
+
+	// Close complete chunked writer
+	Close() error
+
+	// Abort chunk write
+	Abort() error
+}
+
 // UserInfoer is an optional interface for Fs
 type UserInfoer interface {
 	// UserInfo returns info about the connected user
diff --git a/fs/open_options.go b/fs/open_options.go
index 5dc3e32d4..288a36303 100644
--- a/fs/open_options.go
+++ b/fs/open_options.go
@@ -276,6 +276,22 @@ func (o MetadataOption) Mandatory() bool {
 	return false
 }
 
+type ChunkOption struct {
+	ChunkSize int64
+}
+
+func (o *ChunkOption) Header() (key string, value string) {
+	return "chunkSize", fmt.Sprintf("%v", o.ChunkSize)
+}
+
+func (o *ChunkOption) Mandatory() bool {
+	return false
+}
+
+func (o *ChunkOption) String() string {
+	return fmt.Sprintf("ChunkOption(%v)", o.ChunkSize)
+}
+
 // OpenOptionAddHeaders adds each header found in options to the
 // headers map provided the key was non empty.
 func OpenOptionAddHeaders(options []OpenOption, headers map[string]string) {
diff --git a/fs/operations/multithread.go b/fs/operations/multithread.go
index 5ab41b9a2..deb7c88ba 100644
--- a/fs/operations/multithread.go
+++ b/fs/operations/multithread.go
@@ -9,13 +9,13 @@ import (
 
 	"github.com/rclone/rclone/fs"
 	"github.com/rclone/rclone/fs/accounting"
+	"github.com/rclone/rclone/lib/readers"
 	"golang.org/x/sync/errgroup"
+	"golang.org/x/sync/semaphore"
 )
 
 const (
-	multithreadChunkSize      = 64 << 10
-	multithreadChunkSizeMask  = multithreadChunkSize - 1
-	multithreadReadBufferSize = 32 * 1024
+	multithreadChunkSize = 64 << 10
 )
 
 // An offsetWriter maps writes at offset base to offset base+off in the underlying writer.
@@ -60,7 +60,7 @@ func doMultiThreadCopy(ctx context.Context, f fs.Fs, src fs.Object) bool {
 	}
 	// ...destination doesn't support it
 	dstFeatures := f.Features()
-	if dstFeatures.OpenWriterAt == nil {
+	if dstFeatures.OpenChunkWriter == nil && dstFeatures.OpenWriterAt == nil {
 		return false
 	}
 	// ...if --multi-thread-streams not in use and source and
@@ -73,21 +73,20 @@ func doMultiThreadCopy(ctx context.Context, f fs.Fs, src fs.Object) bool {
 
 // state for a multi-thread copy
 type multiThreadCopyState struct {
-	ctx      context.Context
-	partSize int64
-	size     int64
-	wc       fs.WriterAtCloser
-	src      fs.Object
-	acc      *accounting.Account
-	streams  int
+	ctx       context.Context
+	partSize  int64
+	size      int64
+	src       fs.Object
+	acc       *accounting.Account
+	streams   int
+	numChunks int
 }
 
 // Copy a single stream into place
-func (mc *multiThreadCopyState) copyStream(ctx context.Context, stream int) (err error) {
-	ci := fs.GetConfig(ctx)
+func (mc *multiThreadCopyState) copyStream(ctx context.Context, stream int, writer fs.ChunkWriter) (err error) {
 	defer func() {
 		if err != nil {
-			fs.Debugf(mc.src, "multi-thread copy: stream %d/%d failed: %v", stream+1, mc.streams, err)
+			fs.Debugf(mc.src, "multi-thread copy: stream %d/%d failed: %v", stream+1, mc.numChunks, err)
 		}
 	}()
 	start := int64(stream) * mc.partSize
@@ -99,7 +98,7 @@ func (mc *multiThreadCopyState) copyStream(ctx context.Context, stream int) (err
 		end = mc.size
 	}
 
-	fs.Debugf(mc.src, "multi-thread copy: stream %d/%d (%d-%d) size %v starting", stream+1, mc.streams, start, end, fs.SizeSuffix(end-start))
+	fs.Debugf(mc.src, "multi-thread copy: stream %d/%d (%d-%d) size %v starting", stream+1, mc.numChunks, start, end, fs.SizeSuffix(end-start))
 
 	rc, err := Open(ctx, mc.src, &fs.RangeOption{Start: start, End: end - 1})
 	if err != nil {
@@ -107,119 +106,99 @@ func (mc *multiThreadCopyState) copyStream(ctx context.Context, stream int) (err
 	}
 	defer fs.CheckClose(rc, &err)
 
-	var writer io.Writer = newOffsetWriter(mc.wc, start)
-	if ci.MultiThreadWriteBufferSize > 0 {
-		writer = bufio.NewWriterSize(writer, int(ci.MultiThreadWriteBufferSize))
-		fs.Debugf(mc.src, "multi-thread copy: write buffer set to %v", ci.MultiThreadWriteBufferSize)
-	}
-	// Copy the data
-	buf := make([]byte, multithreadReadBufferSize)
-	offset := start
-	for {
-		// Check if context cancelled and exit if so
-		if mc.ctx.Err() != nil {
-			return mc.ctx.Err()
-		}
-		nr, er := rc.Read(buf)
-		if nr > 0 {
-			err = mc.acc.AccountRead(nr)
-			if err != nil {
-				return fmt.Errorf("multipart copy: accounting failed: %w", err)
-			}
-			nw, ew := writer.Write(buf[0:nr])
-			if nw > 0 {
-				offset += int64(nw)
-			}
-			if ew != nil {
-				return fmt.Errorf("multipart copy: write failed: %w", ew)
-			}
-			if nr != nw {
-				return fmt.Errorf("multipart copy: %w", io.ErrShortWrite)
-			}
-		}
-		if er != nil {
-			if er != io.EOF {
-				return fmt.Errorf("multipart copy: read failed: %w", er)
-			}
-
-			// if we were buffering, flush do disk
-			switch w := writer.(type) {
-			case *bufio.Writer:
-				er2 := w.Flush()
-				if er2 != nil {
-					return fmt.Errorf("multipart copy: flush failed: %w", er2)
-				}
-			}
-
-			break
-		}
+	bytesWritten, err := writer.WriteChunk(stream, readers.NewRepeatableReader(rc))
+	if err != nil {
+		return err
 	}
-
-	if offset != end {
-		return fmt.Errorf("multipart copy: wrote %d bytes but expected to write %d", offset-start, end-start)
+	// FIXME: Wrap ReadSeeker for Accounting
+	// However, to ensure reporting is correctly seeks have to be handled properly
+	errAccRead := mc.acc.AccountRead(int(bytesWritten))
+	if errAccRead != nil {
+		return errAccRead
 	}
 
-	fs.Debugf(mc.src, "multi-thread copy: stream %d/%d (%d-%d) size %v finished", stream+1, mc.streams, start, end, fs.SizeSuffix(end-start))
+	fs.Debugf(mc.src, "multi-thread copy: stream %d/%d (%d-%d) size %v finished", stream+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
 	return nil
 }
 
-// Calculate the chunk sizes and updated number of streams
-func (mc *multiThreadCopyState) calculateChunks() {
-	partSize := mc.size / int64(mc.streams)
-	// Round partition size up so partSize * streams >= size
-	if (mc.size % int64(mc.streams)) != 0 {
-		partSize++
-	}
-	// round partSize up to nearest multithreadChunkSize boundary
-	mc.partSize = (partSize + multithreadChunkSizeMask) &^ multithreadChunkSizeMask
-	// recalculate number of streams
-	mc.streams = int(mc.size / mc.partSize)
-	// round streams up so partSize * streams >= size
-	if (mc.size % mc.partSize) != 0 {
-		mc.streams++
+// Given a file size and a chunkSize
+// it returns the number of chunks, so that chunkSize * numChunks >= size
+func calculateNumChunks(size int64, chunkSize int64) int {
+	numChunks := size / chunkSize
+	if size%chunkSize != 0 {
+		numChunks++
 	}
+
+	return int(numChunks)
 }
 
-// Copy src to (f, remote) using streams download threads and the OpenWriterAt feature
+// Copy src to (f, remote) using streams download threads. It tries to use the OpenChunkWriter feature
+// and if that's not available it creates an adapter using OpenWriterAt
 func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object, streams int, tr *accounting.Transfer) (newDst fs.Object, err error) {
-	openWriterAt := f.Features().OpenWriterAt
-	if openWriterAt == nil {
-		return nil, errors.New("multi-thread copy: OpenWriterAt not supported")
+	openChunkWriter := f.Features().OpenChunkWriter
+	ci := fs.GetConfig(ctx)
+	if openChunkWriter == nil {
+		openWriterAt := f.Features().OpenWriterAt
+		if openWriterAt == nil {
+			return nil, errors.New("multi-part copy: neither OpenChunkWriter nor OpenWriterAt supported")
+		}
+		openChunkWriter = openChunkWriterFromOpenWriterAt(openWriterAt, int64(ci.MultiThreadChunkSize), int64(ci.MultiThreadWriteBufferSize), f)
 	}
+
 	if src.Size() < 0 {
-		return nil, errors.New("multi-thread copy: can't copy unknown sized file")
+		return nil, fmt.Errorf("multi-thread copy: can't copy unknown sized file")
 	}
 	if src.Size() == 0 {
-		return nil, errors.New("multi-thread copy: can't copy zero sized file")
+		return nil, fmt.Errorf("multi-thread copy: can't copy zero sized file")
 	}
 
 	g, gCtx := errgroup.WithContext(ctx)
-	mc := &multiThreadCopyState{
-		ctx:     gCtx,
-		size:    src.Size(),
-		src:     src,
-		streams: streams,
+	chunkSize, chunkWriter, err := openChunkWriter(ctx, remote, src)
+
+	if chunkSize > src.Size() {
+		fs.Debugf(src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(chunkSize), fs.SizeSuffix(src.Size()))
+		chunkSize = src.Size()
 	}
-	mc.calculateChunks()
 
-	// Make accounting
-	mc.acc = tr.Account(ctx, nil)
+	numChunks := calculateNumChunks(src.Size(), chunkSize)
+	if streams > numChunks {
+		fs.Debugf(src, "multi-thread copy: number of streams '%d' was bigger than number of chunks '%d'", streams, numChunks)
+		streams = numChunks
+	}
+
+	mc := &multiThreadCopyState{
+		ctx:       gCtx,
+		size:      src.Size(),
+		src:       src,
+		partSize:  chunkSize,
+		streams:   streams,
+		numChunks: numChunks,
+	}
 
-	// create write file handle
-	mc.wc, err = openWriterAt(gCtx, remote, mc.size)
 	if err != nil {
-		return nil, fmt.Errorf("multipart copy: failed to open destination: %w", err)
+		return nil, fmt.Errorf("multipart copy: failed to open chunk writer: %w", err)
 	}
 
-	fs.Debugf(src, "Starting multi-thread copy with %d parts of size %v", mc.streams, fs.SizeSuffix(mc.partSize))
-	for stream := 0; stream < mc.streams; stream++ {
-		stream := stream
+	// Make accounting
+	mc.acc = tr.Account(ctx, nil)
+
+	fs.Debugf(src, "Starting multi-thread copy with %d parts of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), mc.streams)
+	sem := semaphore.NewWeighted(int64(mc.streams))
+	for chunk := 0; chunk < mc.numChunks; chunk++ {
+		fs.Debugf(src, "Acquiring semaphore...")
+		if err := sem.Acquire(ctx, 1); err != nil {
+			fs.Errorf(src, "Failed to acquire semaphore: %v", err)
+			break
+		}
+		currChunk := chunk
 		g.Go(func() (err error) {
-			return mc.copyStream(gCtx, stream)
+			defer sem.Release(1)
+			return mc.copyStream(gCtx, currChunk, chunkWriter)
 		})
 	}
+
 	err = g.Wait()
-	closeErr := mc.wc.Close()
+	closeErr := chunkWriter.Close()
 	if err != nil {
 		return nil, err
 	}
@@ -232,13 +211,94 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
 		return nil, fmt.Errorf("multi-thread copy: failed to find object after copy: %w", err)
 	}
 
-	err = obj.SetModTime(ctx, src.ModTime(ctx))
-	switch err {
-	case nil, fs.ErrorCantSetModTime, fs.ErrorCantSetModTimeWithoutDelete:
-	default:
-		return nil, fmt.Errorf("multi-thread copy: failed to set modification time: %w", err)
+	if f.Features().PartialUploads {
+		err = obj.SetModTime(ctx, src.ModTime(ctx))
+		switch err {
+		case nil, fs.ErrorCantSetModTime, fs.ErrorCantSetModTimeWithoutDelete:
+		default:
+			return nil, fmt.Errorf("multi-thread copy: failed to set modification time: %w", err)
+		}
 	}
 
-	fs.Debugf(src, "Finished multi-thread copy with %d parts of size %v", mc.streams, fs.SizeSuffix(mc.partSize))
+	fs.Debugf(src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
 	return obj, nil
 }
+
+type writerAtChunkWriter struct {
+	ctx             context.Context
+	remote          string
+	size            int64
+	writerAt        fs.WriterAtCloser
+	chunkSize       int64
+	chunks          int
+	writeBufferSize int64
+	f               fs.Fs
+}
+
+func (w writerAtChunkWriter) WriteChunk(chunkNumber int, reader io.ReadSeeker) (int64, error) {
+	fs.Debugf(w.remote, "writing chunk %v", chunkNumber)
+
+	bytesToWrite := w.chunkSize
+	if chunkNumber == (w.chunks-1) && w.size%w.chunkSize != 0 {
+		bytesToWrite = w.size % w.chunkSize
+	}
+
+	var writer io.Writer = newOffsetWriter(w.writerAt, int64(chunkNumber)*w.chunkSize)
+	if w.writeBufferSize > 0 {
+		writer = bufio.NewWriterSize(writer, int(w.writeBufferSize))
+	}
+	n, err := io.Copy(writer, reader)
+	if err != nil {
+		return -1, err
+	}
+	if n != bytesToWrite {
+		return -1, fmt.Errorf("expected to write %v bytes for chunk %v, but wrote %v bytes", bytesToWrite, chunkNumber, n)
+	}
+	// if we were buffering, flush do disk
+	switch w := writer.(type) {
+	case *bufio.Writer:
+		er2 := w.Flush()
+		if er2 != nil {
+			return -1, fmt.Errorf("multipart copy: flush failed: %w", err)
+		}
+	}
+	return n, nil
+}
+
+func (w writerAtChunkWriter) Close() error {
+	return w.writerAt.Close()
+}
+
+func (w writerAtChunkWriter) Abort() error {
+	obj, err := w.f.NewObject(w.ctx, w.remote)
+	if err != nil {
+		return fmt.Errorf("multi-thread copy: failed to find temp file when aborting chunk writer: %w", err)
+	}
+	return obj.Remove(w.ctx)
+}
+
+func openChunkWriterFromOpenWriterAt(openWriterAt func(ctx context.Context, remote string, size int64) (fs.WriterAtCloser, error), chunkSize int64, writeBufferSize int64, f fs.Fs) func(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (chunkSizeResult int64, writer fs.ChunkWriter, err error) {
+	return func(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (chunkSizeResult int64, writer fs.ChunkWriter, err error) {
+		writerAt, err := openWriterAt(ctx, remote, src.Size())
+		if err != nil {
+			return -1, nil, err
+		}
+
+		if writeBufferSize > 0 {
+			fs.Debugf(src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
+		}
+
+		chunkWriter := &writerAtChunkWriter{
+			ctx:             ctx,
+			remote:          remote,
+			size:            src.Size(),
+			chunkSize:       chunkSize,
+			chunks:          calculateNumChunks(src.Size(), chunkSize),
+			writerAt:        writerAt,
+			writeBufferSize: writeBufferSize,
+			f:               f,
+		}
+
+		return chunkSize, chunkWriter, nil
+	}
+}


Test patch:
diff --git a/backend/chunker/chunker_test.go b/backend/chunker/chunker_test.go
index 7f0c0310d..d5ce656dd 100644
--- a/backend/chunker/chunker_test.go
+++ b/backend/chunker/chunker_test.go
@@ -40,6 +40,7 @@ func TestIntegration(t *testing.T) {
 		UnimplementableFsMethods: []string{
 			"PublicLink",
 			"OpenWriterAt",
+			"OpenChunkWriter",
 			"MergeDirs",
 			"DirCacheFlush",
 			"UserInfo",
diff --git a/backend/combine/combine_test.go b/backend/combine/combine_test.go
index 0a0d357dd..c132377b1 100644
--- a/backend/combine/combine_test.go
+++ b/backend/combine/combine_test.go
@@ -11,7 +11,7 @@ import (
 )
 
 var (
-	unimplementableFsMethods     = []string{"UnWrap", "WrapFs", "SetWrapper", "UserInfo", "Disconnect"}
+	unimplementableFsMethods     = []string{"UnWrap", "WrapFs", "SetWrapper", "UserInfo", "Disconnect", "OpenChunkWriter"}
 	unimplementableObjectMethods = []string{}
 )
 
diff --git a/backend/compress/compress_test.go b/backend/compress/compress_test.go
index 356e9dd43..bb74f0aca 100644
--- a/backend/compress/compress_test.go
+++ b/backend/compress/compress_test.go
@@ -45,6 +45,7 @@ func TestRemoteGzip(t *testing.T) {
 		NilObject:  (*Object)(nil),
 		UnimplementableFsMethods: []string{
 			"OpenWriterAt",
+			"OpenChunkWriter",
 			"MergeDirs",
 			"DirCacheFlush",
 			"PutUnchecked",
diff --git a/backend/crypt/crypt_test.go b/backend/crypt/crypt_test.go
index d4b9dc1e4..49db07dc7 100644
--- a/backend/crypt/crypt_test.go
+++ b/backend/crypt/crypt_test.go
@@ -24,7 +24,7 @@ func TestIntegration(t *testing.T) {
 	fstests.Run(t, &fstests.Opt{
 		RemoteName:                   *fstest.RemoteName,
 		NilObject:                    (*crypt.Object)(nil),
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 	})
 }
@@ -45,7 +45,7 @@ func TestStandardBase32(t *testing.T) {
 			{Name: name, Key: "password", Value: obscure.MustObscure("potato")},
 			{Name: name, Key: "filename_encryption", Value: "standard"},
 		},
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
@@ -67,7 +67,7 @@ func TestStandardBase64(t *testing.T) {
 			{Name: name, Key: "filename_encryption", Value: "standard"},
 			{Name: name, Key: "filename_encoding", Value: "base64"},
 		},
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
@@ -89,7 +89,7 @@ func TestStandardBase32768(t *testing.T) {
 			{Name: name, Key: "filename_encryption", Value: "standard"},
 			{Name: name, Key: "filename_encoding", Value: "base32768"},
 		},
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
@@ -111,7 +111,7 @@ func TestOff(t *testing.T) {
 			{Name: name, Key: "password", Value: obscure.MustObscure("potato2")},
 			{Name: name, Key: "filename_encryption", Value: "off"},
 		},
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
@@ -137,7 +137,7 @@ func TestObfuscate(t *testing.T) {
 			{Name: name, Key: "filename_encryption", Value: "obfuscate"},
 		},
 		SkipBadWindowsCharacters:     true,
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
@@ -164,7 +164,7 @@ func TestNoDataObfuscate(t *testing.T) {
 			{Name: name, Key: "no_data_encryption", Value: "true"},
 		},
 		SkipBadWindowsCharacters:     true,
-		UnimplementableFsMethods:     []string{"OpenWriterAt"},
+		UnimplementableFsMethods:     []string{"OpenWriterAt", "OpenChunkWriter"},
 		UnimplementableObjectMethods: []string{"MimeType"},
 		QuickTestOK:                  true,
 	})
diff --git a/backend/hasher/hasher_test.go b/backend/hasher/hasher_test.go
index 23feb9b96..f17303ecc 100644
--- a/backend/hasher/hasher_test.go
+++ b/backend/hasher/hasher_test.go
@@ -23,6 +23,7 @@ func TestIntegration(t *testing.T) {
 		NilObject:  (*hasher.Object)(nil),
 		UnimplementableFsMethods: []string{
 			"OpenWriterAt",
+			"OpenChunkWriter",
 		},
 		UnimplementableObjectMethods: []string{},
 	}
diff --git a/backend/union/union_test.go b/backend/union/union_test.go
index c9482c56c..b9f4e24e2 100644
--- a/backend/union/union_test.go
+++ b/backend/union/union_test.go
@@ -12,7 +12,7 @@ import (
 )
 
 var (
-	unimplementableFsMethods     = []string{"UnWrap", "WrapFs", "SetWrapper", "UserInfo", "Disconnect", "PublicLink", "PutUnchecked", "MergeDirs", "OpenWriterAt"}
+	unimplementableFsMethods     = []string{"UnWrap", "WrapFs", "SetWrapper", "UserInfo", "Disconnect", "PublicLink", "PutUnchecked", "MergeDirs", "OpenWriterAt", "OpenChunkWriter"}
 	unimplementableObjectMethods = []string{}
 )
 
diff --git a/cmd/bisync/bisync_test.go b/cmd/bisync/bisync_test.go
index 7e419c4b9..55e98a19e 100644
--- a/cmd/bisync/bisync_test.go
+++ b/cmd/bisync/bisync_test.go
@@ -614,6 +614,8 @@ func (b *bisyncTest) runBisync(ctx context.Context, args []string) (err error) {
 			opt.DryRun = true
 		case "force":
 			opt.Force = true
+		case "create-empty-src-dirs":
+			opt.CreateEmptySrcDirs = true
 		case "remove-empty-dirs":
 			opt.RemoveEmptyDirs = true
 		case "check-sync-only":
@@ -1163,6 +1165,10 @@ func (b *bisyncTest) newReplacer(mangle bool) *strings.Replacer {
 		b.workDir + slash, "{workdir/}",
 		b.path1, "{path1/}",
 		b.path2, "{path2/}",
+		"//?/" + strings.TrimSuffix(strings.Replace(b.path1, slash, "/", -1), "/"), "{path1}", // fix windows-specific issue
+		"//?/" + strings.TrimSuffix(strings.Replace(b.path2, slash, "/", -1), "/"), "{path2}",
+		strings.TrimSuffix(b.path1, slash), "{path1}", // ensure it's still recognized without trailing slash
+		strings.TrimSuffix(b.path2, slash), "{path2}",
 		b.sessionName, "{session}",
 	}
 	if fixSlash {
diff --git a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst
index 49142c3e6..ddb01f4a7 100644
--- a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst
+++ b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst
@@ -4,7 +4,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst-new b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst-new
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst-new
+++ b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path1.lst-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst
index 49142c3e6..ddb01f4a7 100644
--- a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst
+++ b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst
@@ -4,7 +4,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst-new b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst-new
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst-new
+++ b/cmd/bisync/testdata/test_changes/golden/_testdir_path1.._testdir_path2.path2.lst-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_changes/golden/test.log b/cmd/bisync/testdata/test_changes/golden/test.log
index 1b314688d..222b29758 100644
--- a/cmd/bisync/testdata/test_changes/golden/test.log
+++ b/cmd/bisync/testdata/test_changes/golden/test.log
@@ -68,6 +68,11 @@ INFO  : - Path2    File was deleted                    - file7.txt
 INFO  : - Path2    File was deleted                    - file8.txt
 INFO  : Path2:    7 changes:    1 new,    3 newer,    0 older,    3 deleted
 INFO  : Applying changes
+INFO  : Checking potential conflicts...
+ERROR : file5.txt: md5 differ
+NOTICE: Local file system at {path2}: 1 differences found
+NOTICE: Local file system at {path2}: 1 errors while checking
+INFO  : Finished checking the potential conflicts. 1 differences found
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file11.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file2.txt
 INFO  : - Path2    Queue delete                        - {path2/}file4.txt
diff --git a/cmd/bisync/testdata/test_changes/modfiles/file5L.txt b/cmd/bisync/testdata/test_changes/modfiles/file5L.txt
index 464147f09..43ceff1db 100644
--- a/cmd/bisync/testdata/test_changes/modfiles/file5L.txt
+++ b/cmd/bisync/testdata/test_changes/modfiles/file5L.txt
@@ -1,1 +1,1 @@
-This file is newer
+This file is newer and not equal to 5R
diff --git a/cmd/bisync/testdata/test_changes/modfiles/file5R.txt b/cmd/bisync/testdata/test_changes/modfiles/file5R.txt
index 464147f09..a928fcf13 100644
--- a/cmd/bisync/testdata/test_changes/modfiles/file5R.txt
+++ b/cmd/bisync/testdata/test_changes/modfiles/file5R.txt
@@ -1,1 +1,1 @@
-This file is newer
+This file is newer and not equal to 5L
diff --git a/cmd/bisync/testdata/test_check_filename/golden/test.log b/cmd/bisync/testdata/test_check_filename/golden/test.log
index 78d0555ac..d008b934d 100644
--- a/cmd/bisync/testdata/test_check_filename/golden/test.log
+++ b/cmd/bisync/testdata/test_check_filename/golden/test.log
@@ -39,10 +39,12 @@ Bisync error: bisync aborted
 (10)  : move-listings path2-missing
 
 (11)  : test 3. put the remote subdir .chk_file back, run resync.
-(12)  : copy-file {path1/}subdir/.chk_file {path2/}
+(12)  : copy-file {path1/}subdir/.chk_file {path2/}subdir/
 (13)  : bisync check-access resync check-filename=.chk_file
 INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
 INFO  : Copying unique Path2 files to Path1
+INFO  : Checking access health
+INFO  : Found 2 matching ".chk_file" files on both paths
 INFO  : Resynching Path1 to Path2
 INFO  : Resync updating listings
 INFO  : Bisync successful
diff --git a/cmd/bisync/testdata/test_check_filename/scenario.txt b/cmd/bisync/testdata/test_check_filename/scenario.txt
index 3d0b0ab49..d20a3ee48 100644
--- a/cmd/bisync/testdata/test_check_filename/scenario.txt
+++ b/cmd/bisync/testdata/test_check_filename/scenario.txt
@@ -20,7 +20,7 @@ bisync check-access check-filename=.chk_file
 move-listings path2-missing
 
 test 3. put the remote subdir .chk_file back, run resync.
-copy-file {path1/}subdir/.chk_file {path2/}
+copy-file {path1/}subdir/.chk_file {path2/}subdir/
 bisync check-access resync check-filename=.chk_file
 
 test 4. run sync with check-access. should pass.
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.copy1to2.que b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.copy1to2.que
new file mode 100644
index 000000000..4f985e416
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.copy1to2.que
@@ -0,0 +1,1 @@
+"subdir"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.delete2.que b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.delete2.que
new file mode 100644
index 000000000..4f985e416
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.delete2.que
@@ -0,0 +1,1 @@
+"subdir"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst
new file mode 100644
index 000000000..5d46d4069
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst
@@ -0,0 +1,7 @@
+# bisync listing v1 from test
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy3.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy4.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy5.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.txt"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst-new b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst-new
new file mode 100644
index 000000000..5d46d4069
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path1.lst-new
@@ -0,0 +1,7 @@
+# bisync listing v1 from test
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy3.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy4.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy5.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.txt"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst
new file mode 100644
index 000000000..5d46d4069
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst
@@ -0,0 +1,7 @@
+# bisync listing v1 from test
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy3.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy4.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy5.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.txt"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst-new b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst-new
new file mode 100644
index 000000000..5d46d4069
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.path2.lst-new
@@ -0,0 +1,7 @@
+# bisync listing v1 from test
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy3.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy4.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.copy5.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2001-01-02T00:00:00.000000000+0000 "file1.txt"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.resync-copy2to1.que b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.resync-copy2to1.que
new file mode 100644
index 000000000..4f985e416
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/_testdir_path1.._testdir_path2.resync-copy2to1.que
@@ -0,0 +1,1 @@
+"subdir"
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/golden/test.log b/cmd/bisync/testdata/test_createemptysrcdirs/golden/test.log
new file mode 100644
index 000000000..b8799524a
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/golden/test.log
@@ -0,0 +1,142 @@
+(01)  : test createemptysrcdirs
+
+
+(02)  : test initial bisync
+(03)  : touch-glob 2001-01-02 {datadir/} placeholder.txt
+(04)  : copy-as {datadir/}placeholder.txt {path1/} file1.txt
+(05)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+(06)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+(07)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+(08)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+(09)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+(10)  : bisync resync
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Copying unique Path2 files to Path1
+INFO  : Resynching Path1 to Path2
+INFO  : Resync updating listings
+INFO  : Bisync successful
+
+(11)  : test 1. Create an empty dir on Path1 by creating subdir/placeholder.txt and then deleting the placeholder
+(12)  : copy-as {datadir/}placeholder.txt {path1/} subdir/placeholder.txt
+(13)  : touch-glob 2001-01-02 {path1/} subdir
+(14)  : delete-file {path1/}subdir/placeholder.txt
+
+(15)  : test 2. Run bisync without --create-empty-src-dirs
+(16)  : bisync
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : Path2 checking for diffs
+INFO  : No changes found
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
+
+(17)  : test 3. Confirm the subdir exists only on Path1 and not Path2
+(18)  : list-dirs {path1/}
+subdir/
+(19)  : list-dirs {path2/}
+
+(20)  : test 4.Run bisync WITH --create-empty-src-dirs
+(21)  : bisync create-empty-src-dirs
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : - Path1    File is new                         - subdir
+INFO  : Path1:    1 changes:    1 new,    0 newer,    0 older,    0 deleted
+INFO  : Path2 checking for diffs
+INFO  : Applying changes
+INFO  : - Path1    Queue copy to Path2                 - {path2/}subdir
+INFO  : - Path1    Do queued copies to                 - Path2
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
+
+(22)  : test 5. Confirm the subdir exists on both paths
+(23)  : list-dirs {path1/}
+subdir/
+(24)  : list-dirs {path2/}
+subdir/
+
+(25)  : test 6. Delete the empty dir on Path1 using purge-children (and also add files so the path isn't empty)
+(26)  : purge-children {path1/}
+(27)  : copy-as {datadir/}placeholder.txt {path1/} file1.txt
+(28)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+(29)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+(30)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+(31)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+(32)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+
+(33)  : test 7. Run bisync without --create-empty-src-dirs
+(34)  : bisync
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : - Path1    File was deleted                    - RCLONE_TEST
+INFO  : - Path1    File was deleted                    - subdir
+INFO  : Path1:    2 changes:    0 new,    0 newer,    0 older,    2 deleted
+INFO  : Path2 checking for diffs
+INFO  : - Path2    File was deleted                    - subdir
+INFO  : Path2:    1 changes:    0 new,    0 newer,    0 older,    1 deleted
+INFO  : Applying changes
+INFO  : - Path2    Queue delete                        - {path2/}RCLONE_TEST
+INFO  : -          Do queued deletes on                - Path2
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
+
+(35)  : test 8. Confirm the subdir exists only on Path2 and not Path1
+(36)  : list-dirs {path1/}
+(37)  : list-dirs {path2/}
+subdir/
+
+(38)  : test 9. Reset, do the delete again, and run bisync WITH --create-empty-src-dirs
+(39)  : bisync resync create-empty-src-dirs
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Copying unique Path2 files to Path1
+INFO  : - Path2    Resync will copy to Path1           - subdir
+INFO  : - Path2    Resync is doing queued copies to    - Path1
+INFO  : Resynching Path1 to Path2
+INFO  : Resynching Path2 to Path1 (for empty dirs)
+INFO  : Resync updating listings
+INFO  : Bisync successful
+(40)  : list-dirs {path1/}
+subdir/
+(41)  : list-dirs {path2/}
+subdir/
+
+(42)  : purge-children {path1/}
+(43)  : copy-as {datadir/}placeholder.txt {path1/} file1.txt
+(44)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+(45)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+(46)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+(47)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+(48)  : copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+(49)  : list-dirs {path1/}
+(50)  : list-dirs {path2/}
+subdir/
+
+(51)  : bisync create-empty-src-dirs
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : - Path1    File was deleted                    - subdir
+INFO  : Path1:    1 changes:    0 new,    0 newer,    0 older,    1 deleted
+INFO  : Path2 checking for diffs
+INFO  : Applying changes
+INFO  : - Path2    Queue delete                        - {path2/}subdir
+INFO  : -          Do queued deletes on                - Path2
+INFO  : subdir: Removing directory
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
+
+(52)  : test 10. Confirm the subdir has been removed on both paths
+(53)  : list-dirs {path1/}
+(54)  : list-dirs {path2/}
+
+(55)  : test 11. bisync again (because if we leave subdir in listings, test will fail due to mismatched modtime)
+(56)  : bisync create-empty-src-dirs
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : Path2 checking for diffs
+INFO  : No changes found
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/RCLONE_TEST b/cmd/bisync/testdata/test_createemptysrcdirs/initial/RCLONE_TEST
new file mode 100644
index 000000000..d8ca97c2a
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/initial/RCLONE_TEST
@@ -0,0 +1,1 @@
+This file is used for testing the health of rclone accesses to the local/remote file system.  Do not delete.
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy1.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy1.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy2.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy2.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy3.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy3.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy4.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy4.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy5.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.copy5.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.txt b/cmd/bisync/testdata/test_createemptysrcdirs/initial/file1.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/modfiles/placeholder.txt b/cmd/bisync/testdata/test_createemptysrcdirs/modfiles/placeholder.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_createemptysrcdirs/scenario.txt b/cmd/bisync/testdata/test_createemptysrcdirs/scenario.txt
new file mode 100644
index 000000000..8b39ae2b5
--- /dev/null
+++ b/cmd/bisync/testdata/test_createemptysrcdirs/scenario.txt
@@ -0,0 +1,87 @@
+test createemptysrcdirs
+# Test the --create-empty-src-dirs logic.
+# Should behave the same way as rclone sync.
+# Without this flag, empty directories created/deleted on one side are NOT created/deleted on the other side
+# With this flag, empty directories created/deleted on one side are created/deleted on the other side; the result should be an exact mirror.
+#
+# Placeholders are necessary to ensure that git does not lose our empty folders
+# After the initial setup sync:
+#  1. Create an empty dir on Path1 by creating subdir/placeholder.txt and then deleting the placeholder
+#  2. Run bisync without --create-empty-src-dirs
+#  3. Confirm the subdir exists only on Path1 and not Path2
+#  4. Run bisync WITH --create-empty-src-dirs
+#  5. Confirm the subdir exists on both paths
+#  6. Delete the empty dir on Path1 using purge-children (and also add files so the path isn't empty)
+#  7. Run bisync without --create-empty-src-dirs
+#  8. Confirm the subdir exists only on Path2 and not Path1
+#  9. Reset, do the delete again, and run bisync WITH --create-empty-src-dirs
+#  10. Confirm the subdir has been removed on both paths
+
+test initial bisync
+touch-glob 2001-01-02 {datadir/} placeholder.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+bisync resync
+
+test 1. Create an empty dir on Path1 by creating subdir/placeholder.txt and then deleting the placeholder
+copy-as {datadir/}placeholder.txt {path1/} subdir/placeholder.txt
+touch-glob 2001-01-02 {path1/} subdir
+delete-file {path1/}subdir/placeholder.txt
+
+test 2. Run bisync without --create-empty-src-dirs
+bisync
+
+test 3. Confirm the subdir exists only on Path1 and not Path2
+list-dirs {path1/}
+list-dirs {path2/}
+
+test 4.Run bisync WITH --create-empty-src-dirs
+bisync create-empty-src-dirs
+
+test 5. Confirm the subdir exists on both paths
+list-dirs {path1/}
+list-dirs {path2/}
+
+test 6. Delete the empty dir on Path1 using purge-children (and also add files so the path isn't empty)
+purge-children {path1/}
+copy-as {datadir/}placeholder.txt {path1/} file1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+
+test 7. Run bisync without --create-empty-src-dirs
+bisync
+
+test 8. Confirm the subdir exists only on Path2 and not Path1
+list-dirs {path1/}
+list-dirs {path2/}
+
+test 9. Reset, do the delete again, and run bisync WITH --create-empty-src-dirs
+bisync resync create-empty-src-dirs
+list-dirs {path1/}
+list-dirs {path2/}
+
+purge-children {path1/}
+copy-as {datadir/}placeholder.txt {path1/} file1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy1.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy2.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy3.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy4.txt
+copy-as {datadir/}placeholder.txt {path1/} file1.copy5.txt
+list-dirs {path1/}
+list-dirs {path2/}
+
+bisync create-empty-src-dirs
+
+test 10. Confirm the subdir has been removed on both paths
+list-dirs {path1/}
+list-dirs {path2/}
+
+test 11. bisync again (because if we leave subdir in listings, test will fail due to mismatched modtime)
+bisync create-empty-src-dirs
\ No newline at end of file
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst
index 49142c3e6..ddb01f4a7 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst
@@ -4,7 +4,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-new b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-new
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path1.lst-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst
index 49142c3e6..ddb01f4a7 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst
@@ -4,7 +4,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt..path1"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt..path2"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-new b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-new
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/_testdir_path1.._testdir_path2.path2.lst-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry-new
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path1.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry-new
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun-resync._testdir_path1.._testdir_path2.path2.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry-new
index 239afb58c..8ea562aab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path1.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file11.txt"
 -       13 md5:fb3ecfb2800400fb01b0bfd39903e9fb - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file3.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:0860a03592626642f8fd6c8bfb447d2a - 2001-03-04T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file7.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry-new b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry-new
index 377ac6a2a..ba1b8daab 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry-new
+++ b/cmd/bisync/testdata/test_dry_run/golden/dryrun._testdir_path1.._testdir_path2.path2.lst-dry-new
@@ -4,5 +4,5 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file10.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
 -        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file4.txt"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
+-       39 md5:979a803b15d27df0c31ad7d29006d10b - 2001-01-02T00:00:00.000000000+0000 "file5.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "file6.txt"
diff --git a/cmd/bisync/testdata/test_dry_run/golden/test.log b/cmd/bisync/testdata/test_dry_run/golden/test.log
index 2efdb1f81..13bd9654a 100644
--- a/cmd/bisync/testdata/test_dry_run/golden/test.log
+++ b/cmd/bisync/testdata/test_dry_run/golden/test.log
@@ -54,13 +54,10 @@ NOTICE: file4.txt: Skipped copy as --dry-run is set (size 0)
 NOTICE: file6.txt: Skipped copy as --dry-run is set (size 19)
 INFO  : Resynching Path1 to Path2
 NOTICE: file1.txt: Skipped copy as --dry-run is set (size 0)
-NOTICE: file10.txt: Skipped delete as --dry-run is set (size 19)
 NOTICE: file11.txt: Skipped copy as --dry-run is set (size 19)
 NOTICE: file2.txt: Skipped copy as --dry-run is set (size 13)
 NOTICE: file3.txt: Skipped copy as --dry-run is set (size 0)
-NOTICE: file4.txt: Skipped delete as --dry-run is set (size 0)
-NOTICE: file5.txt: Skipped copy (or update modification time) as --dry-run is set (size 19)
-NOTICE: file6.txt: Skipped delete as --dry-run is set (size 19)
+NOTICE: file5.txt: Skipped copy (or update modification time) as --dry-run is set (size 39)
 NOTICE: file7.txt: Skipped copy as --dry-run is set (size 19)
 INFO  : Resync updating listings
 INFO  : Bisync successful
@@ -86,15 +83,20 @@ INFO  : - Path2    File was deleted                    - file3.txt
 INFO  : - Path2    File was deleted                    - file7.txt
 INFO  : Path2:    6 changes:    1 new,    3 newer,    0 older,    2 deleted
 INFO  : Applying changes
+INFO  : Checking potential conflicts...
+ERROR : file5.txt: md5 differ
+NOTICE: Local file system at {path2}: 1 differences found
+NOTICE: Local file system at {path2}: 1 errors while checking
+INFO  : Finished checking the potential conflicts. 1 differences found
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file11.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file2.txt
 INFO  : - Path2    Queue delete                        - {path2/}file4.txt
 NOTICE: - WARNING  New or changed in both paths        - file5.txt
 NOTICE: - Path1    Renaming Path1 copy                 - {path1/}file5.txt..path1
-NOTICE: file5.txt: Skipped move as --dry-run is set (size 19)
+NOTICE: file5.txt: Skipped move as --dry-run is set (size 39)
 NOTICE: - Path1    Queue copy to Path2                 - {path2/}file5.txt..path1
 NOTICE: - Path2    Renaming Path2 copy                 - {path2/}file5.txt..path2
-NOTICE: file5.txt: Skipped move as --dry-run is set (size 19)
+NOTICE: file5.txt: Skipped move as --dry-run is set (size 39)
 NOTICE: - Path2    Queue copy to Path1                 - {path1/}file5.txt..path2
 INFO  : - Path2    Queue copy to Path1                 - {path1/}file6.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file7.txt
@@ -137,6 +139,11 @@ INFO  : - Path2    File was deleted                    - file3.txt
 INFO  : - Path2    File was deleted                    - file7.txt
 INFO  : Path2:    6 changes:    1 new,    3 newer,    0 older,    2 deleted
 INFO  : Applying changes
+INFO  : Checking potential conflicts...
+ERROR : file5.txt: md5 differ
+NOTICE: Local file system at {path2}: 1 differences found
+NOTICE: Local file system at {path2}: 1 errors while checking
+INFO  : Finished checking the potential conflicts. 1 differences found
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file11.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file2.txt
 INFO  : - Path2    Queue delete                        - {path2/}file4.txt
diff --git a/cmd/bisync/testdata/test_dry_run/modfiles/file5L.txt b/cmd/bisync/testdata/test_dry_run/modfiles/file5L.txt
index 464147f09..43ceff1db 100644
--- a/cmd/bisync/testdata/test_dry_run/modfiles/file5L.txt
+++ b/cmd/bisync/testdata/test_dry_run/modfiles/file5L.txt
@@ -1,1 +1,1 @@
-This file is newer
+This file is newer and not equal to 5R
diff --git a/cmd/bisync/testdata/test_dry_run/modfiles/file5R.txt b/cmd/bisync/testdata/test_dry_run/modfiles/file5R.txt
index 464147f09..a928fcf13 100644
--- a/cmd/bisync/testdata/test_dry_run/modfiles/file5R.txt
+++ b/cmd/bisync/testdata/test_dry_run/modfiles/file5R.txt
@@ -1,1 +1,1 @@
-This file is newer
+This file is newer and not equal to 5L
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy1to2.que b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy1to2.que
new file mode 100644
index 000000000..d988a5ab1
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy1to2.que
@@ -0,0 +1,1 @@
+"file1.txt..path1"
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy2to1.que b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy2to1.que
new file mode 100644
index 000000000..7f99cd1ce
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.copy2to1.que
@@ -0,0 +1,1 @@
+"file1.txt..path2"
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst
new file mode 100644
index 000000000..8ef63fed8
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-       33 md5:ea683c03f780b76a62405456b08ae6fd - 2001-03-04T00:00:00.000000000+0000 "file1.txt..path1"
+-       33 md5:2b4975bb20f7be674e66d78570ba2fb1 - 2001-01-02T00:00:00.000000000+0000 "file1.txt..path2"
+-       37 md5:9fe822ddd1cb81d83aae00fa48239bd3 - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst-new b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst-new
new file mode 100644
index 000000000..eb2bbe4cf
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path1.lst-new
@@ -0,0 +1,4 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-       33 md5:ea683c03f780b76a62405456b08ae6fd - 2001-03-04T00:00:00.000000000+0000 "file1.txt"
+-       37 md5:9fe822ddd1cb81d83aae00fa48239bd3 - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst
new file mode 100644
index 000000000..8ef63fed8
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-       33 md5:ea683c03f780b76a62405456b08ae6fd - 2001-03-04T00:00:00.000000000+0000 "file1.txt..path1"
+-       33 md5:2b4975bb20f7be674e66d78570ba2fb1 - 2001-01-02T00:00:00.000000000+0000 "file1.txt..path2"
+-       37 md5:9fe822ddd1cb81d83aae00fa48239bd3 - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
diff --git a/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst-new b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst-new
new file mode 100644
index 000000000..8bea8b845
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/_testdir_path1.._testdir_path2.path2.lst-new
@@ -0,0 +1,4 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-       33 md5:2b4975bb20f7be674e66d78570ba2fb1 - 2001-01-02T00:00:00.000000000+0000 "file1.txt"
+-       37 md5:9fe822ddd1cb81d83aae00fa48239bd3 - 2001-01-02T00:00:00.000000000+0000 "file2.txt"
diff --git a/cmd/bisync/testdata/test_equal/golden/test.log b/cmd/bisync/testdata/test_equal/golden/test.log
new file mode 100644
index 000000000..34cffc037
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/golden/test.log
@@ -0,0 +1,52 @@
+(01)  : test equal
+
+
+(02)  : test initial bisync
+(03)  : bisync resync
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Copying unique Path2 files to Path1
+INFO  : Resynching Path1 to Path2
+INFO  : Resync updating listings
+INFO  : Bisync successful
+
+(04)  : test changed on both paths and NOT identical - file1 (file1R, file1L)
+(05)  : touch-glob 2001-01-02 {datadir/} file1R.txt
+(06)  : copy-as {datadir/}file1R.txt {path2/} file1.txt
+(07)  : touch-glob 2001-03-04 {datadir/} file1L.txt
+(08)  : copy-as {datadir/}file1L.txt {path1/} file1.txt
+
+(09)  : test changed on both paths and identical - file2
+(10)  : touch-glob 2001-01-02 {datadir/} file2.txt
+(11)  : copy-as {datadir/}file2.txt {path1/} file2.txt
+(12)  : copy-as {datadir/}file2.txt {path2/} file2.txt
+
+(13)  : test bisync run
+(14)  : bisync
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Path1 checking for diffs
+INFO  : - Path1    File is newer                       - file1.txt
+INFO  : - Path1    File is newer                       - file2.txt
+INFO  : Path1:    2 changes:    0 new,    2 newer,    0 older,    0 deleted
+INFO  : Path2 checking for diffs
+INFO  : - Path2    File is newer                       - file1.txt
+INFO  : - Path2    File is newer                       - file2.txt
+INFO  : Path2:    2 changes:    0 new,    2 newer,    0 older,    0 deleted
+INFO  : Applying changes
+INFO  : Checking potential conflicts...
+ERROR : file1.txt: md5 differ
+NOTICE: Local file system at {path2}: 1 differences found
+NOTICE: Local file system at {path2}: 1 errors while checking
+NOTICE: Local file system at {path2}: 1 matching files
+INFO  : Finished checking the potential conflicts. 1 differences found
+NOTICE: - WARNING  New or changed in both paths        - file1.txt
+NOTICE: - Path1    Renaming Path1 copy                 - {path1/}file1.txt..path1
+NOTICE: - Path1    Queue copy to Path2                 - {path2/}file1.txt..path1
+NOTICE: - Path2    Renaming Path2 copy                 - {path2/}file1.txt..path2
+NOTICE: - Path2    Queue copy to Path1                 - {path1/}file1.txt..path2
+NOTICE: - WARNING  New or changed in both paths        - file2.txt
+INFO  : Files are equal! Skipping: file2.txt
+INFO  : - Path2    Do queued copies to                 - Path1
+INFO  : - Path1    Do queued copies to                 - Path2
+INFO  : Updating listings
+INFO  : Validating listings for Path1 "{path1/}" vs Path2 "{path2/}"
+INFO  : Bisync successful
diff --git a/cmd/bisync/testdata/test_equal/initial/RCLONE_TEST b/cmd/bisync/testdata/test_equal/initial/RCLONE_TEST
new file mode 100644
index 000000000..d8ca97c2a
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/initial/RCLONE_TEST
@@ -0,0 +1,1 @@
+This file is used for testing the health of rclone accesses to the local/remote file system.  Do not delete.
diff --git a/cmd/bisync/testdata/test_equal/initial/file1.txt b/cmd/bisync/testdata/test_equal/initial/file1.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_equal/initial/file2.txt b/cmd/bisync/testdata/test_equal/initial/file2.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/bisync/testdata/test_equal/modfiles/file1L.txt b/cmd/bisync/testdata/test_equal/modfiles/file1L.txt
new file mode 100644
index 000000000..6906ccb09
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/modfiles/file1L.txt
@@ -0,0 +1,1 @@
+This file is NOT identical to 1R
diff --git a/cmd/bisync/testdata/test_equal/modfiles/file1R.txt b/cmd/bisync/testdata/test_equal/modfiles/file1R.txt
new file mode 100644
index 000000000..e49d06a94
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/modfiles/file1R.txt
@@ -0,0 +1,1 @@
+This file is NOT identical to 1L
diff --git a/cmd/bisync/testdata/test_equal/modfiles/file2.txt b/cmd/bisync/testdata/test_equal/modfiles/file2.txt
new file mode 100644
index 000000000..2ee5fc1ee
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/modfiles/file2.txt
@@ -0,0 +1,1 @@
+This file is identical on both sides
diff --git a/cmd/bisync/testdata/test_equal/scenario.txt b/cmd/bisync/testdata/test_equal/scenario.txt
new file mode 100644
index 000000000..86fd26aca
--- /dev/null
+++ b/cmd/bisync/testdata/test_equal/scenario.txt
@@ -0,0 +1,22 @@
+test equal
+# Check that changed files on both sides are renamed ONLY if not-identical
+# See: https://forum.rclone.org/t/bisync-bugs-and-feature-requests/37636#:~:text=1.%20Identical%20files%20should%20be%20left%20alone%2C%20even%20if%20new/newer/changed%20on%20both%20sides
+# - Changed on Path2 and on Path1, and NOT identical       file1 (file1r, file1l)
+# - Changed on Path2 and on Path1, and identical           file2
+
+test initial bisync
+bisync resync
+
+test changed on both paths and NOT identical - file1 (file1R, file1L)
+touch-glob 2001-01-02 {datadir/} file1R.txt
+copy-as {datadir/}file1R.txt {path2/} file1.txt
+touch-glob 2001-03-04 {datadir/} file1L.txt
+copy-as {datadir/}file1L.txt {path1/} file1.txt
+
+test changed on both paths and identical - file2
+touch-glob 2001-01-02 {datadir/} file2.txt
+copy-as {datadir/}file2.txt {path1/} file2.txt
+copy-as {datadir/}file2.txt {path2/} file2.txt
+
+test bisync run
+bisync
diff --git a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path1.lst b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path1.lst
index 60009c3fc..2d9313ea4 100644
--- a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path1.lst
+++ b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path1.lst
@@ -12,7 +12,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_rawchars_␙_\x81_\xfe/file3_␙_\x81_\xfe"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/file_with_測試_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-03T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path2"
+-       42 md5:40b811fb5009223b6da573f169619d8e - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path2"
 -      272 md5:0033328434f9662a7dae0d1aee7768b6 - 2000-01-01T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_p2s.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/mañana_funcionará.txt"
diff --git a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst
index 60009c3fc..2d9313ea4 100644
--- a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst
+++ b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst
@@ -12,7 +12,7 @@
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_rawchars_␙_\x81_\xfe/file3_␙_\x81_\xfe"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/file_with_測試_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-03T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path1"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path2"
+-       42 md5:40b811fb5009223b6da573f169619d8e - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt..path2"
 -      272 md5:0033328434f9662a7dae0d1aee7768b6 - 2000-01-01T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_p2s.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/mañana_funcionará.txt"
diff --git a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst-new b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst-new
index 5f3e1c9f1..850de84a6 100644
--- a/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst-new
+++ b/cmd/bisync/testdata/test_extended_filenames/golden/_testdir_path1.._testdir_path2.path2.lst-new
@@ -8,7 +8,7 @@
 -      272 md5:0033328434f9662a7dae0d1aee7768b6 - 2000-01-01T00:00:00.000000000+0000 "filename_contains_ě_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir with␊white space.txt/file2 with␊white space.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_rawchars_␙_\x81_\xfe/file3_␙_\x81_\xfe"
--       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt"
+-       42 md5:40b811fb5009223b6da573f169619d8e - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt"
 -      272 md5:0033328434f9662a7dae0d1aee7768b6 - 2000-01-01T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "subdir_with_ࢺ_/filename_contains_ࢺ_p2s.txt"
 -       19 md5:7fe98ed88552b828777d8630900346b8 - 2001-01-02T00:00:00.000000000+0000 "Русский.txt"
diff --git a/cmd/bisync/testdata/test_extended_filenames/golden/test.log b/cmd/bisync/testdata/test_extended_filenames/golden/test.log
index 9aef61002..5a7f6412a 100644
--- a/cmd/bisync/testdata/test_extended_filenames/golden/test.log
+++ b/cmd/bisync/testdata/test_extended_filenames/golden/test.log
@@ -12,30 +12,31 @@ INFO  : Bisync successful
 (04)  : test place a newer files on both paths
 
 (05)  : touch-glob 2001-01-02 {datadir/} file1.txt
-(06)  : copy-as {datadir/}file1.txt {path2/} New_top_level_mañana_funcionará.txt
-(07)  : copy-as {datadir/}file1.txt {path2/} file_enconde_mañana_funcionará.txt
-(08)  : copy-as {datadir/}file1.txt {path1/} filename_contains_ࢺ_p1m.txt
-(09)  : copy-as {datadir/}file1.txt {path2/} Русский.txt
-(10)  : copy-as {datadir/}file1.txt {path1/} file1_with{spc}white{spc}space.txt
-(11)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ test.txt
-(12)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ mañana_funcionará.txt
-(13)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ file_with_測試_.txt
-(14)  : copy-as {datadir/}file1.txt {path2/}subdir_with_ࢺ_ filename_contains_ࢺ_p2s.txt
-(15)  : copy-as {datadir/}file1.txt {path2/}subdir{spc}with{eol}white{spc}space.txt file2{spc}with{eol}white{spc}space.txt
-(16)  : copy-as {datadir/}file1.txt {path2/}subdir_rawchars_{chr:19}_{chr:81}_{chr:fe} file3_{chr:19}_{chr:81}_{chr:fe}
+(06)  : touch-glob 2001-01-02 {datadir/} file2.txt
+(07)  : copy-as {datadir/}file1.txt {path2/} New_top_level_mañana_funcionará.txt
+(08)  : copy-as {datadir/}file1.txt {path2/} file_enconde_mañana_funcionará.txt
+(09)  : copy-as {datadir/}file1.txt {path1/} filename_contains_ࢺ_p1m.txt
+(10)  : copy-as {datadir/}file1.txt {path2/} Русский.txt
+(11)  : copy-as {datadir/}file1.txt {path1/} file1_with{spc}white{spc}space.txt
+(12)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ test.txt
+(13)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ mañana_funcionará.txt
+(14)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ file_with_測試_.txt
+(15)  : copy-as {datadir/}file1.txt {path2/}subdir_with_ࢺ_ filename_contains_ࢺ_p2s.txt
+(16)  : copy-as {datadir/}file1.txt {path2/}subdir{spc}with{eol}white{spc}space.txt file2{spc}with{eol}white{spc}space.txt
+(17)  : copy-as {datadir/}file1.txt {path2/}subdir_rawchars_{chr:19}_{chr:81}_{chr:fe} file3_{chr:19}_{chr:81}_{chr:fe}
 
-(17)  : test place a new file on both paths
-(18)  : copy-as {datadir/}file1.txt {path2/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
-(19)  : touch-glob 2001-01-03 {datadir/} file1.txt
-(20)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
+(18)  : test place a new file on both paths
+(19)  : copy-as {datadir/}file2.txt {path2/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
+(20)  : touch-glob 2001-01-03 {datadir/} file1.txt
+(21)  : copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
 
-(21)  : test delete files on both paths
-(22)  : delete-file {path2/}filename_contains_ࢺ_.txt
-(23)  : delete-file {path2/}subdir_with_ࢺ_/filename_contains_ě_.txt
-(24)  : delete-file {path1/}Русский.txt
+(22)  : test delete files on both paths
+(23)  : delete-file {path2/}filename_contains_ࢺ_.txt
+(24)  : delete-file {path2/}subdir_with_ࢺ_/filename_contains_ě_.txt
+(25)  : delete-file {path1/}Русский.txt
 
-(25)  : test bisync run
-(26)  : bisync
+(26)  : test bisync run
+(27)  : bisync
 INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
 INFO  : Path1 checking for diffs
 INFO  : - Path1    File is new                         - file1_with white space.txt
@@ -58,6 +59,11 @@ INFO  : - Path2    File was deleted                    - filename_contains_ࢺ_.
 INFO  : - Path2    File was deleted                    - subdir_with_ࢺ_/filename_contains_ě_.txt
 INFO  : Path2:    9 changes:    5 new,    2 newer,    0 older,    2 deleted
 INFO  : Applying changes
+INFO  : Checking potential conflicts...
+ERROR : subdir_with_ࢺ_/filechangedbothpaths_ࢺ_.txt: sizes differ
+NOTICE: Local file system at {path2}: 1 differences found
+NOTICE: Local file system at {path2}: 1 errors while checking
+INFO  : Finished checking the potential conflicts. 1 differences found
 INFO  : - Path1    Queue copy to Path2                 - {path2/}file1_with white space.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}filename_contains_ࢺ_p1m.txt
 INFO  : - Path1    Queue copy to Path2                 - {path2/}subdir_with_ࢺ_/file_with_測試_.txt
diff --git a/cmd/bisync/testdata/test_extended_filenames/modfiles/file2.txt b/cmd/bisync/testdata/test_extended_filenames/modfiles/file2.txt
new file mode 100644
index 000000000..76f450b35
--- /dev/null
+++ b/cmd/bisync/testdata/test_extended_filenames/modfiles/file2.txt
@@ -0,0 +1,1 @@
+This file is newer and not equal to file1
diff --git a/cmd/bisync/testdata/test_extended_filenames/scenario.txt b/cmd/bisync/testdata/test_extended_filenames/scenario.txt
index 7c722720a..774f5db8f 100644
--- a/cmd/bisync/testdata/test_extended_filenames/scenario.txt
+++ b/cmd/bisync/testdata/test_extended_filenames/scenario.txt
@@ -16,6 +16,7 @@ bisync resync
 test place a newer files on both paths
 # force specific modification time since file time is lost through git
 touch-glob 2001-01-02 {datadir/} file1.txt
+touch-glob 2001-01-02 {datadir/} file2.txt
 copy-as {datadir/}file1.txt {path2/} New_top_level_mañana_funcionará.txt
 copy-as {datadir/}file1.txt {path2/} file_enconde_mañana_funcionará.txt
 copy-as {datadir/}file1.txt {path1/} filename_contains_ࢺ_p1m.txt
@@ -29,7 +30,7 @@ copy-as {datadir/}file1.txt {path2/}subdir{spc}with{eol}white{spc}space.txt file
 copy-as {datadir/}file1.txt {path2/}subdir_rawchars_{chr:19}_{chr:81}_{chr:fe} file3_{chr:19}_{chr:81}_{chr:fe}
 
 test place a new file on both paths
-copy-as {datadir/}file1.txt {path2/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
+copy-as {datadir/}file2.txt {path2/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
 touch-glob 2001-01-03 {datadir/} file1.txt
 copy-as {datadir/}file1.txt {path1/}subdir_with_ࢺ_ filechangedbothpaths_ࢺ_.txt
 
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry
new file mode 100644
index 000000000..844684626
--- /dev/null
+++ b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "subdir/file20.txt"
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new
new file mode 100644
index 000000000..844684626
--- /dev/null
+++ b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path1.lst-dry-new
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "subdir/file20.txt"
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry
new file mode 100644
index 000000000..844684626
--- /dev/null
+++ b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "subdir/file20.txt"
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new
new file mode 100644
index 000000000..844684626
--- /dev/null
+++ b/cmd/bisync/testdata/test_filtersfile_checks/golden/_testdir_path1.._testdir_path2.path2.lst-dry-new
@@ -0,0 +1,5 @@
+# bisync listing v1 from test
+-      109 md5:294d25b294ff26a5243dba914ac3fbf7 - 2000-01-01T00:00:00.000000000+0000 "RCLONE_TEST"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file1.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "file2.txt"
+-        0 md5:d41d8cd98f00b204e9800998ecf8427e - 2000-01-01T00:00:00.000000000+0000 "subdir/file20.txt"
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/golden/test.log b/cmd/bisync/testdata/test_filtersfile_checks/golden/test.log
index 606e06ef7..ab50cf9cf 100644
--- a/cmd/bisync/testdata/test_filtersfile_checks/golden/test.log
+++ b/cmd/bisync/testdata/test_filtersfile_checks/golden/test.log
@@ -58,3 +58,21 @@ INFO  : Using filters file {workdir/}filtersfile.txt
 ERROR : Bisync critical error: filters file has changed (must run --resync): {workdir/}filtersfile.txt
 ERROR : Bisync aborted. Must run --resync to recover.
 Bisync error: bisync aborted
+
+(18)  : test 8. run with filters-file and resync and dry-run. should do the dry-run but still cause next non-resync run to abort.
+(19)  : bisync filters-file={workdir/}filtersfile.txt resync dry-run
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Using filters file {workdir/}filtersfile.txt
+INFO  : Skipped storing filters file hash to {workdir/}filtersfile.txt.md5 as --dry-run is set
+INFO  : Copying unique Path2 files to Path1
+INFO  : Resynching Path1 to Path2
+INFO  : Resync updating listings
+INFO  : Bisync successful
+
+(20)  : test 9. run with filters-file alone. should abort.
+(21)  : bisync filters-file={workdir/}filtersfile.txt
+INFO  : Synching Path1 "{path1/}" with Path2 "{path2/}"
+INFO  : Using filters file {workdir/}filtersfile.txt
+ERROR : Bisync critical error: filters file has changed (must run --resync): {workdir/}filtersfile.txt
+ERROR : Bisync aborted. Must run --resync to recover.
+Bisync error: bisync aborted
diff --git a/cmd/bisync/testdata/test_filtersfile_checks/scenario.txt b/cmd/bisync/testdata/test_filtersfile_checks/scenario.txt
index 0cb206cef..fa8054799 100644
--- a/cmd/bisync/testdata/test_filtersfile_checks/scenario.txt
+++ b/cmd/bisync/testdata/test_filtersfile_checks/scenario.txt
@@ -34,3 +34,9 @@ copy-as {datadir/}filtersfile2.txt {workdir/} filtersfile.txt
 
 test 7. run with filters-file alone. should abort.
 bisync filters-file={workdir/}filtersfile.txt
+
+test 8. run with filters-file and resync and dry-run. should do the dry-run but still cause next non-resync run to abort.
+bisync filters-file={workdir/}filtersfile.txt resync dry-run
+
+test 9. run with filters-file alone. should abort.
+bisync filters-file={workdir/}filtersfile.txt
\ No newline at end of file
diff --git a/fs/operations/multithread_test.go b/fs/operations/multithread_test.go
index aef6f75af..cd5f430f8 100644
--- a/fs/operations/multithread_test.go
+++ b/fs/operations/multithread_test.go
@@ -86,27 +86,24 @@ func TestDoMultiThreadCopy(t *testing.T) {
 	assert.True(t, doMultiThreadCopy(ctx, f, src))
 }
 
-func TestMultithreadCalculateChunks(t *testing.T) {
+func TestMultithreadCalculateNumChunks(t *testing.T) {
 	for _, test := range []struct {
-		size         int64
-		streams      int
-		wantPartSize int64
-		wantStreams  int
+		size          int64
+		chunkSize     int64
+		wantNumChunks int
 	}{
-		{size: 1, streams: 10, wantPartSize: multithreadChunkSize, wantStreams: 1},
-		{size: 1 << 20, streams: 1, wantPartSize: 1 << 20, wantStreams: 1},
-		{size: 1 << 20, streams: 2, wantPartSize: 1 << 19, wantStreams: 2},
-		{size: (1 << 20) + 1, streams: 2, wantPartSize: (1 << 19) + multithreadChunkSize, wantStreams: 2},
-		{size: (1 << 20) - 1, streams: 2, wantPartSize: (1 << 19), wantStreams: 2},
+		{size: 1, chunkSize: multithreadChunkSize, wantNumChunks: 1},
+		{size: 1 << 20, chunkSize: 1, wantNumChunks: 1 << 20},
+		{size: 1 << 20, chunkSize: 2, wantNumChunks: 1 << 19},
+		{size: (1 << 20) + 1, chunkSize: 2, wantNumChunks: (1 << 19) + 1},
+		{size: (1 << 20) - 1, chunkSize: 2, wantNumChunks: 1 << 19},
 	} {
 		t.Run(fmt.Sprintf("%+v", test), func(t *testing.T) {
 			mc := &multiThreadCopyState{
-				size:    test.size,
-				streams: test.streams,
+				size: test.size,
 			}
-			mc.calculateChunks()
-			assert.Equal(t, test.wantPartSize, mc.partSize)
-			assert.Equal(t, test.wantStreams, mc.streams)
+			mc.numChunks = calculateNumChunks(test.size, test.chunkSize)
+			assert.Equal(t, test.wantNumChunks, mc.numChunks)
 		})
 	}
 }
diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go
index 99ec177b8..5ac7978cf 100644
--- a/fstest/fstests/fstests.go
+++ b/fstest/fstests/fstests.go
@@ -785,6 +785,52 @@ func Run(t *testing.T, opt *Opt) {
 			assert.NoError(t, f.Rmdir(ctx, "writer-at-subdir"))
 		})
 
+		// TestFsOpenChunkWriter tests writing in chunks to fs
+		// then reads back the contents and check if they match
+		// go test -v -run 'TestIntegration/FsMkdir/FsOpenChunkWriter'
+		t.Run("FsOpenChunkWriter", func(t *testing.T) {
+			skipIfNotOk(t)
+			openChunkWriter := f.Features().OpenChunkWriter
+			if openChunkWriter == nil {
+				t.Skip("FS has no OpenChunkWriter interface")
+			}
+			size5MBs := 5 * 1024 * 1024
+			contents1 := random.String(size5MBs)
+			contents2 := random.String(size5MBs)
+
+			size1MB := 1 * 1024 * 1024
+			contents3 := random.String(size1MB)
+
+			path := "writer-at-subdir/writer-at-file"
+			objSrc := object.NewStaticObjectInfo(path, file1.ModTime, -1, true, nil, nil)
+			_, out, err := openChunkWriter(ctx, objSrc.Remote(), objSrc, &fs.ChunkOption{
+				ChunkSize: int64(size5MBs),
+			})
+			require.NoError(t, err)
+
+			var n int64
+			n, err = out.WriteChunk(1, strings.NewReader(contents2))
+			assert.NoError(t, err)
+			assert.Equal(t, int64(size5MBs), n)
+			n, err = out.WriteChunk(2, strings.NewReader(contents3))
+			assert.NoError(t, err)
+			assert.Equal(t, int64(size1MB), n)
+			n, err = out.WriteChunk(0, strings.NewReader(contents1))
+			assert.NoError(t, err)
+			assert.Equal(t, int64(size5MBs), n)
+
+			assert.NoError(t, out.Close())
+
+			obj := findObject(ctx, t, f, path)
+			originalContents := contents1 + contents2 + contents3
+			fileContents := ReadObject(ctx, t, obj, -1)
+			isEqual := originalContents == fileContents
+			assert.True(t, isEqual, "contents of file differ")
+
+			assert.NoError(t, obj.Remove(ctx))
+			assert.NoError(t, f.Rmdir(ctx, "writer-at-subdir"))
+		})
+
 		// TestFsChangeNotify tests that changes are properly
 		// propagated
 		//