fix: don't silently drop data on short reads in concurrent WriteTo - #660
fix: don't silently drop data on short reads in concurrent WriteTo#660ChrisJr404 wants to merge 1 commit into
Conversation
| // chunk's error. | ||
| if n < chunkSize { | ||
| var m int | ||
| m, err = f.readChunkAt(readWork.res, b[n:], readWork.off+int64(n)) |
There was a problem hiding this comment.
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.
|
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. |
|
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.
0397474 to
538c743
Compare
|
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
left a comment
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
[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.
| // 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") |
There was a problem hiding this comment.
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.
| // Close the client first (LIFO), so its receive loop sees the server go away. | ||
| defer client.Close() | ||
| defer server.Close() |
There was a problem hiding this comment.
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.
|
|
||
| // The default server max packet size is 32768, so a bigger client packet | ||
| // size makes every read come back short. | ||
| server, err := NewServer(struct { |
There was a problem hiding this comment.
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.
| if err == nil { | ||
| t.Fatalf("WriteTo succeeded but should have reported the short read; wrote %d of %d bytes", n, len(want)) | ||
| } |
There was a problem hiding this comment.
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.
Fixes #658.
The concurrent
File.WriteTopath (whatio.Copyuses) dispatches reads at offsets spacedchunkSizeapart 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 -raceandgo vetare green on the package.