fix(ftp): end the data wait on a refusal the control reader already holds - #698
Merged
Conversation
…olds When a server sends the preliminary 150 and the final 4xx/5xx reply in one segment, suppaftp's BufReader absorbs both while reading the 150. The bare socket then shows nothing, poll_peek returns 0, and the transfer waits out the full 1800s data idle timeout for an answer that already arrived. Reproduced first with an in-memory socket pair: one write carrying both replies, the data socket held open and silent, the refusal asserted inside the modelled reader buffer and the socket asserted empty. The new test timed out on the pinned base and passes with the fix. The watch now looks at both places a reply can wait: suppaftp exposes the reader's buffered bytes (fork axpdev-lab/suppaftp, one method, pinned by rev with a written exit condition), and read_watching_control classifies them exactly like the socket peek. 4/5 ends the wait, anything else shortens the deadline and is left for the finalizer, and the refusal itself is still consumed through the BufReader by read_queued_refusal. The buffer holds plaintext even under FTPS, so the refusal class is decidable there too. Signed-off-by: axpnet <45786925+axpnet@users.noreply.github.com> Co-Authored-By: Kimi K3 <noreply@moonshot.cn> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAyRPuLt7UymiLNfkiyT7
|
Warning Review limit reachedNext included review available in 9 minutes. View limit detailsLimit details: You’ve used all 2 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
The exit condition said the upstream PR was prepared and waiting to be sent. Reading suppaftp's own rules makes that overstated in two ways, and an exit condition that overstates readiness is as unverifiable as one that says nothing. CONTRIBUTING.md asks for an issue and maintainer feedback BEFORE the code, and the pull request checklist wants tests covering the change plus a local `just check_code` (dprint, clippy, docs, cargo-deny). Neither exists for this method, so the branch being pushed is not the contribution being ready. AI_POLICY.md is binding and has consequences: disclosure is mandatory, it must go through their pull request template, and the contributor must be a human who understands the change and answers review in their own words rather than relaying an assistant's replies. Naming that here is the honest thing to do, because this method and its documentation were written by an AI agent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAyRPuLt7UymiLNfkiyT7 Signed-off-by: axpnet <45786925+axpnet@users.noreply.github.com>
`cargo audit` matches advisories by crate name and version against the crates.io index, and a git source is not that. While the suppaftp patch is in place the crate sits outside the scanning its released version would get, so an advisory landing on it may never turn the audit lane red. The exposure itself is unchanged, since the pin is v10.0.2 plus one additive method. What is lost is the signal, which is the part worth writing down: a cost that only shows up as silence is the kind that gets forgotten once the build goes green, and it is the strongest practical reason to file upstream instead of settling into the fork. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAyRPuLt7UymiLNfkiyT7 Signed-off-by: axpnet <45786925+axpnet@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Pre-release audit finding A-04 (lane A, marked PLAUSIBLE, no fixture run by
the auditor): the data-channel watch in
read_watching_controlpeeks thebare TCP socket UNDERNEATH suppaftp's
BufReader. When a server sends thepreliminary
150and the final4xx/5xxreply in ONE segment, the read thatcollects the
150pulls the whole segment into the reader's buffer. Thesocket then shows nothing,
poll_peekreturns 0, and the transfer waits outthe full
DATA_IDLE_TIMEOUT(1800 s) for an answer that is already insidethe process.
Reproduction first
The first step was not the fix, it was making the defect happen. New test
a_refusal_absorbed_by_the_reader_ends_the_wait:150 ...\r\n550 ...\r\nin a single write (one segment,no flush between, which is precisely the condition of the finding; two
separate writes would have proved nothing);
what the reader did when it collected the
150;ARE inside the modelled reader buffer, and the socket is empty
(
WouldBlock);at the head of
read_watching_control(the data socket closing at onceand ending the wait by another road) cannot hide the stall.
On the pinned base this test FAILED with a 5 s timeout: the wait never
ended, exactly as predicted. Red seen before the fix.
The fix
The observation point was wrong, not the case: looking at the socket under
the
BufReadercannot establish that no reply is already queued. suppaftp10.0.2 does not expose the reader's buffered bytes (the field is private;
only
get_ref()is public), so:axpdev-lab/suppaftp, branchbuffered-reply-bytesoff tagv10.0.2,adds exactly one method,
buffered_reply_bytes()(self.reader.buffer()),consuming nothing.
git diff v10.0.2on the fork shows only that method.Pinned by
revin[patch.crates-io], same shape as the existingn0-mainline patch, with a written exit condition (drop the patch once an
upstream release exposes the buffered bytes; upstream PR to be filed from
the same branch).
read_watching_controlnow also takes the buffered bytes. InQuietstate a buffered reply is classified from its first byte, same rule as the
socket peek (
4/5ends the wait, anything else shortens the deadlineand leaves the bytes for the finalizer). In
Spokestate an expireddeadline counts as a refusal when the socket OR the buffer holds bytes.
FTPS, so the 4xx/5xx classification now works where the wire bytes would
be TLS records.
The refusal itself is still consumed by
read_queued_refusal, which readsthrough the
BufReaderand therefore sees the buffered reply. The reply isnever eaten by the watch.
Verified
not end) and passes with the fix.
a_completion_reply_is_left_alone_and_the_data_keeps_flowingwith its"peek did not eat the reply" assertion.
cargo fmt --all -- --check, clippy and the full pre-push gate: seebelow, updated before merge if anything moved.
NOT verified, and which way each limit leans
Docker fixture. On loopback the real flow closes the data socket at once
and the wait ends by another road in about two seconds, so a Docker
fixture could not have shown the stall anyway (measured constraint, noted
in the code at the head of
read_watching_control). The limit leanstoward the defect being rarer in practice on loopback, unchanged on real
networks.
after_timed_out_open(550then421in onesegment, session kept with the
421queued) is NOT fixed here: it istracked separately in the code, and the keep/drop session decision it
feeds is its own change. The new accessor makes it solvable; the comment
there now says so.
crate builds.
Added during the recovery
The gate was run WITH the patch active, which is the thing the unit test cannot cover: a forked dependency can break builds the test never touches.
cargo test --lib -- providers::ftp::: 29 passed, 0 failed, 1 ignored.cargo clippy --all-targets -- -D warnings: clean.cargo fmt --all -- --check: clean.Cargo.lockalready carries the git source and the pinned rev, so the patch resolves in CI and not only on one disk. Verified before it was asked for.axpdev-lab/suppaftp, branchbuffered-reply-bytes, fork ofveeso/suppaftp), so[patch.crates-io]resolves for anybody, not just here.The exit condition was rewritten, because it was inert. It said "an upstream PR is to be filed from the same branch": a future tense with no subject and no link, which six months from now reads like something that was done. It now states the three things a later reader can check: who has to send it (
@axpnet), the status in plain words (NOT OPENED as of 2026-09-01), and where the URL goes when it exists.A fork does not become permanent because the exit condition is missing. It becomes permanent because the exit condition is written in a form nobody can verify.
The upstream PR is prepared and deliberately NOT sent. Filing a PR on a third-party repository under the account owner's name is an outward-facing action that belongs to the owner, not to whoever is holding the keyboard. Branch, diff and text are ready and the send is one command; it is waiting on a decision, and the Cargo.toml says so out loud.
Was the fork avoidable
Checked against the crate source rather than assumed. In
suppaftp10.0.2,ImplAsyncFtpStreamkeepsreader: BufReader<DataStream<T>>private, and the only accessor isget_ref(), which returns theTcpStreamunderneath the buffer. From outside the crate those bytes are not observable at all.The two alternatives, and why they lose:
read_watching_control:read_response_inis not cancellation-safe, and atimeoutaround it drops it mid-line and misaligns the control channel. That replaces a control channel we ignore with one we corrupt.git diff v10.0.2.So the fork is the honest minimum here, and it has a house precedent in the n0-mainline patch directly above it.
Merge record
Merged at zero incomplete check runs, read from the check-runs API on the head commit rather than from this page, where a superseded
cancelledrun reads as red.No review bot read this branch: CodeRabbit was rate limited across the whole range
5ea980f1..0345f759, verified across all three surfaces (issue comments, submitted reviews, inline comments on this head) rather than only the first, which is how two Major findings on a sibling pull request were nearly missed tonight.The work is Kimi K3's, including the part that matters most: a test that fails on the pinned base FOR THE PREDICTED REASON, with the
150and the550leaving in one write, the refusal asserted to be inside the reader's buffer and the socket asserted to be empty, and the data channel held open and silent. The chosen vehicle is a direct test on the watch with an in-memory socket pair, not a Docker fixture: the file itself records that on loopback the data socket closes at once and the wait ends by itself, so a fixture there could have shown nothing for a reason unrelated to the defect and the silence would have read as "does not reproduce".This ships a forked dependency, and that is a decision rather than a detail. suppaftp keeps its reader private and
get_ref()returns the socket UNDER the buffer, so the bytes already absorbed are not observable from outside the crate, verified in the registry source rather than taken on trust. The fork adds one method and nothing else, auditable withgit diff v10.0.2. The two alternatives were rejected for reasons already written in this file: consuming instead of observing puts a non-cancellation-safe read inside aselect!, which replaces a control channel we ignore with one we corrupt, and owning the response layer ourselves is a rewrite on the eve of a tag.The exit condition in
Cargo.tomlnames WHO must file the upstream pull request, states that it is not open as of 2026-09-01, and says where the URL goes when it exists. It is prepared and deliberately not sent: opening a pull request against a third-party repository in the owner's name is the owner's call.