Skip to content

fix: don't silently drop data on short reads in concurrent WriteTo - #660

Open
ChrisJr404 wants to merge 1 commit into
pkg:masterfrom
ChrisJr404:fix-writeto-short-reads
Open

fix: don't silently drop data on short reads in concurrent WriteTo#660
ChrisJr404 wants to merge 1 commit into
pkg:masterfrom
ChrisJr404:fix-writeto-short-reads

Conversation

@ChrisJr404

Copy link
Copy Markdown

Fixes #658.

The concurrent File.WriteTo path (what io.Copy uses) dispatches reads at offsets spaced chunkSize apart and advances the offset unconditionally. Since a server is free to return a short read, and does whenever the client max packet size is larger than the server's, every chunk kept only its first short read and the rest of that chunk was skipped. The copy came back truncated with a nil error, so it was silent corruption rather than an error.

The sequential paths already handle this by looping in readChunkAt. This does the same thing in the worker: when the pre-dispatched read comes back short, fill the rest of the chunk before handing it on, so the pre-computed offsets stay aligned. A genuine EOF ends the fill and is carried through as the chunk's error. The full-read path (server returns the whole chunk) is unchanged.

Added a regression test that opens a file through an in-memory server with a smaller max packet size than the client. It fails on master (WriteTo returns ~1/4 of the file) and passes with this change. go test, go test -race and go vet are green on the package.

Comment thread client.go Outdated
// chunk's error.
if n < chunkSize {
var m int
m, err = f.readChunkAt(readWork.res, b[n:], readWork.off+int64(n))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The problem is this is that we’ve already dispatched reads to later offsets, catch up reads like this result in non-sequential access, which has been a result of various bugs here and there.

Namely, some ssh implementation automatically delete a file once the whole file has been read the first time, this backfilling catch up would break under this situation.

Also, backing up and backfilling can cause significant performance degradation as servers are tuned for sequential read access, not really for random access.

@ChrisJr404

Copy link
Copy Markdown
Author

Thanks, that is a good catch and not a case I had considered. If a catch-up read backfills an earlier offset after later reads have already been dispatched, that breaks the sequential-access assumption, and on servers that drop a file once it has been fully read the backfill would fail outright. That is worse than the short-read gap I was trying to close.

Given that, silently backfilling is the wrong fix. Would you prefer I detect the short read and return an explicit error to the caller rather than trying to paper over it, or is there an existing pattern here you would rather I follow? Happy to rework it in whichever direction you think is safe.

@puellanivis

Copy link
Copy Markdown
Collaborator

Yes, we should prefer returning an explicit error.

The concurrent WriteTo path dispatches reads at fixed offsets, so a server
returning fewer bytes than requested mid-stream leaves a gap the following
chunk cannot fill. Detect that in the reduce step and return an explicit error
instead of silently dropping the skipped bytes. A short final chunk is still
fine, since it is followed only by the EOF packet, which carries no data.
@ChrisJr404
ChrisJr404 force-pushed the fix-writeto-short-reads branch from 0397474 to 538c743 Compare September 2, 2026 19:50
@ChrisJr404

Copy link
Copy Markdown
Author

Agreed, backfilling was the wrong fix since the catch-up reads reintroduce exactly the non-sequential access you flagged. Reworked it: the worker code is back to the original, and the reduce step now tracks whether a chunk came back short. If a short read is followed by another chunk that still has data, the fixed-offset stream can no longer be reassembled, so WriteTo returns an explicit error rather than silently dropping the skipped bytes. A short final chunk is unaffected since it is only followed by the EOF packet. Updated the test to assert the loud failure and that whatever was written is a correct prefix of the source. Thanks for the guidance.

@puellanivis puellanivis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As an additional comment. The language of responses has hinted at some amount of LLM contribution.

We haven’t yet established any specific policy on LLM contributions for this specific package, but I still recommend that people follow https://docs.kernel.org/process/coding-assistants.html when contributing any amount of LLM code in general. Specifically, calling out Assisted-By: in the commit, so that contributions are properly documented.

FYI: Comments in the review have been addressed with the presumption that they will be acted on by an LLM. If an LLM is used to respond back, do not repeat shared context, do not answer in full formal essay responses. Present your new information and only new information concisely, like one would to someone who already knows what’s going on.

Comment thread client.go
Comment on lines +1569 to +1574
// The reads are dispatched at fixed offsets (off, off+chunkSize, ...), so a
// server returning fewer bytes than requested mid-stream leaves a gap that the
// following chunk cannot fill. If a short read is followed by another chunk that
// still has data, the stream can no longer be reassembled, so fail loudly rather
// than silently drop the skipped bytes. A short final chunk is fine: it is
// followed only by the EOF packet, which carries no data.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[style] Do not break lines just to keep a specific line length: https://go.dev/wiki/CodeReviewComments#line-length

If you’re looking for guidance on how I recommend breaking up comments into multiple lines, I recommend Semantic Breaking. The additional advantage of semantic breaking of comments is that editing of a single idea in the comment will not cause a cascade formatting change of the rest of the comment, which generates unnecessary PR churn.

Comment thread client.go
// than silently drop the skipped bytes. A short final chunk is fine: it is
// followed only by the EOF packet, which carries no data.
if shortRead && len(packet.b) > 0 {
return written, errors.New("sftp: server returned a short read mid-stream, cannot reassemble concurrent WriteTo")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would suggest better "concurrent WriteTo was aborted" rather than “cannot reassemble concurrent WriteTo” The later says nothing especially useful about what happened, the former says definitively: I refused to continue under these conditions.

Comment thread client_test.go
Comment on lines +228 to +230
// Close the client first (LIFO), so its receive loop sees the server go away.
defer client.Close()
defer server.Close()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This comment is misleading. defer statements are executed as noted in LIFO order. So, we are not closing the client first, we are setting up the client to close after the server.

Rework this to a defer func() { … }() so that the proper intended ordering off the instruction is both clear, and identical to the natural order a human would expect events to happen in.

Comment thread client_test.go

// The default server max packet size is 32768, so a bigger client packet
// size makes every read come back short.
server, err := NewServer(struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since this test runs against a server, it is properly an integration test, not a quick test. Since it only runs against our own server implementation, it should also not run when we’re running with -testserver.

Comment thread client_test.go
Comment on lines +259 to +261
if err == nil {
t.Fatalf("WriteTo succeeded but should have reported the short read; wrote %d of %d bytes", n, len(want))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We’ve setup the current conditions that would cause this error to arise, but we’re inherently relying here upon the implementation of the server.

Reasonably, there should be a fix to the server that ensures we do not generally return short packets. In such a case, this test would then fail, because the underlying issue was addressed on both ends: the client returns an error on a short read, but the server also does not return short reads in the first place.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File.WriteTo silently drops data on short reads when the server's max packet size is smaller than the client's

2 participants