Skip to content

Commit e571e75

Browse files
committed
Fix race conditions in FTP close
1 parent 2bf2010 commit e571e75

File tree

2 files changed

+295
-1
lines changed

2 files changed

+295
-1
lines changed

ftp/src/main/java/ch/cyberduck/core/ftp/FTPClient.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ protected Socket _openDataConnection_(final String command, final String arg) th
8484
if(null == socket) {
8585
throw new FTPException(this.getReplyCode(), this.getReplyString());
8686
}
87-
return socket;
87+
// Wrap socket to ensure proper TCP shutdown sequence
88+
return new FTPSocket(socket);
8889
}
8990

9091
@Override
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
package ch.cyberduck.core.ftp;
2+
3+
/*
4+
* Copyright (c) 2002-2025 David Kocher. All rights reserved.
5+
* http://cyberduck.ch/
6+
*
7+
* This program is free software; you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as published by
9+
* the Free Software Foundation; either version 2 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* Bug fixes, suggestions and comments should be sent to [email protected]
18+
*/
19+
20+
import ch.cyberduck.core.ConnectionTimeout;
21+
import ch.cyberduck.core.ConnectionTimeoutFactory;
22+
23+
import org.apache.logging.log4j.LogManager;
24+
import org.apache.logging.log4j.Logger;
25+
26+
import java.io.FilterInputStream;
27+
import java.io.FilterOutputStream;
28+
import java.io.IOException;
29+
import java.io.InputStream;
30+
import java.io.OutputStream;
31+
import java.net.Socket;
32+
import java.net.SocketAddress;
33+
import java.net.SocketException;
34+
import java.nio.channels.SocketChannel;
35+
36+
/**
37+
* Socket wrapper that enforces proper TCP shutdown sequence to prevent
38+
* race conditions due to premature socket closure during FTP data connections.
39+
* <p>
40+
* This fixes issues where:
41+
* - macOS: Intermittent "426 Connection closed; transfer aborted" errors due to socket closure before server ACKs
42+
* - Windows: Intermittent transfer hanging due to socket closure before client sends FIN with last data packet
43+
* <p>
44+
* The proper TCP shutdown sequence is:
45+
* 1. Call shutdownOutput() to send FIN while keeping socket input open for ACKs
46+
* 2. Drain input to wait for ACKs until we receive server FIN
47+
* 3. Close the socket to release resources
48+
*/
49+
public class FTPSocket extends Socket {
50+
private static final Logger log = LogManager.getLogger(FTPSocket.class);
51+
52+
private final Socket delegate;
53+
54+
private final ConnectionTimeout connectionTimeoutPreferences = ConnectionTimeoutFactory.get();
55+
56+
public FTPSocket(final Socket delegate) {
57+
this.delegate = delegate;
58+
}
59+
60+
@Override
61+
public void close() throws IOException {
62+
try {
63+
if(delegate.isClosed() || delegate.isOutputShutdown() || !delegate.isConnected()) {
64+
log.debug("Socket already closed {}", delegate);
65+
}
66+
else {
67+
// Shutdown output to send FIN, but keep socket open to receive ACKs
68+
log.debug("Shutting down output for socket {}", delegate);
69+
delegate.shutdownOutput();
70+
71+
// Wait for server FIN by draining any remaining data
72+
// This ensures all our data packets are ACKed before closing
73+
try {
74+
// Avoid hanging in case the server malfunctions
75+
delegate.setSoTimeout(connectionTimeoutPreferences.getTimeout() * 1000);
76+
InputStream in = delegate.getInputStream();
77+
byte[] buffer = new byte[8192];
78+
int bytesRead;
79+
// Read until EOF (server closes its side) or timeout
80+
while((bytesRead = in.read(buffer)) != -1) {
81+
log.debug("Drained {} bytes from socket after shutdown", bytesRead);
82+
}
83+
log.debug("Received EOF from server, all data acknowledged");
84+
}
85+
catch(java.net.SocketTimeoutException e) {
86+
// Timeout is acceptable - server may not close its side
87+
log.debug("Timeout waiting for server EOF, proceeding with close");
88+
}
89+
catch(IOException e) {
90+
// Other errors during drain are acceptable
91+
log.debug("Error draining socket: {}", e.getMessage());
92+
}
93+
}
94+
}
95+
catch(IOException e) {
96+
log.warn("Failed to shutdown output for socket {}: {}", delegate, e.getMessage());
97+
}
98+
finally {
99+
log.debug("Closing socket {}", delegate);
100+
delegate.close();
101+
}
102+
}
103+
104+
@Override
105+
public void connect(SocketAddress endpoint) throws IOException {
106+
delegate.connect(endpoint);
107+
}
108+
109+
@Override
110+
public void connect(SocketAddress endpoint, int timeout) throws IOException {
111+
delegate.connect(endpoint, timeout);
112+
}
113+
114+
@Override
115+
public void bind(SocketAddress bindpoint) throws IOException {
116+
delegate.bind(bindpoint);
117+
}
118+
119+
@Override
120+
public SocketAddress getRemoteSocketAddress() {
121+
return delegate.getRemoteSocketAddress();
122+
}
123+
124+
@Override
125+
public SocketAddress getLocalSocketAddress() {
126+
return delegate.getLocalSocketAddress();
127+
}
128+
129+
@Override
130+
public SocketChannel getChannel() {
131+
return delegate.getChannel();
132+
}
133+
134+
@Override
135+
public InputStream getInputStream() throws IOException {
136+
return new FilterInputStream(delegate.getInputStream()) {
137+
@Override
138+
public void close() throws IOException {
139+
FTPSocket.this.close(); // Call wrapper's close, not delegate's
140+
}
141+
};
142+
}
143+
144+
@Override
145+
public OutputStream getOutputStream() throws IOException {
146+
return new FilterOutputStream(delegate.getOutputStream()) {
147+
@Override
148+
public void close() throws IOException {
149+
FTPSocket.this.close(); // Call wrapper's close, not delegate's
150+
}
151+
};
152+
}
153+
154+
@Override
155+
public void setTcpNoDelay(boolean on) throws SocketException {
156+
delegate.setTcpNoDelay(on);
157+
}
158+
159+
@Override
160+
public boolean getTcpNoDelay() throws SocketException {
161+
return delegate.getTcpNoDelay();
162+
}
163+
164+
@Override
165+
public void setSoLinger(boolean on, int linger) throws SocketException {
166+
delegate.setSoLinger(on, linger);
167+
}
168+
169+
@Override
170+
public int getSoLinger() throws SocketException {
171+
return delegate.getSoLinger();
172+
}
173+
174+
@Override
175+
public void sendUrgentData(int data) throws IOException {
176+
delegate.sendUrgentData(data);
177+
}
178+
179+
@Override
180+
public void setOOBInline(boolean on) throws SocketException {
181+
delegate.setOOBInline(on);
182+
}
183+
184+
@Override
185+
public boolean getOOBInline() throws SocketException {
186+
return delegate.getOOBInline();
187+
}
188+
189+
@Override
190+
public void setSoTimeout(int timeout) throws SocketException {
191+
delegate.setSoTimeout(timeout);
192+
}
193+
194+
@Override
195+
public int getSoTimeout() throws SocketException {
196+
return delegate.getSoTimeout();
197+
}
198+
199+
@Override
200+
public void setSendBufferSize(int size) throws SocketException {
201+
delegate.setSendBufferSize(size);
202+
}
203+
204+
@Override
205+
public int getSendBufferSize() throws SocketException {
206+
return delegate.getSendBufferSize();
207+
}
208+
209+
@Override
210+
public void setReceiveBufferSize(int size) throws SocketException {
211+
delegate.setReceiveBufferSize(size);
212+
}
213+
214+
@Override
215+
public int getReceiveBufferSize() throws SocketException {
216+
return delegate.getReceiveBufferSize();
217+
}
218+
219+
@Override
220+
public void setKeepAlive(boolean on) throws SocketException {
221+
delegate.setKeepAlive(on);
222+
}
223+
224+
@Override
225+
public boolean getKeepAlive() throws SocketException {
226+
return delegate.getKeepAlive();
227+
}
228+
229+
@Override
230+
public void setTrafficClass(int tc) throws SocketException {
231+
delegate.setTrafficClass(tc);
232+
}
233+
234+
@Override
235+
public int getTrafficClass() throws SocketException {
236+
return delegate.getTrafficClass();
237+
}
238+
239+
@Override
240+
public void setReuseAddress(boolean on) throws SocketException {
241+
delegate.setReuseAddress(on);
242+
}
243+
244+
@Override
245+
public boolean getReuseAddress() throws SocketException {
246+
return delegate.getReuseAddress();
247+
}
248+
249+
@Override
250+
public void shutdownInput() throws IOException {
251+
delegate.shutdownInput();
252+
}
253+
254+
@Override
255+
public void shutdownOutput() throws IOException {
256+
delegate.shutdownOutput();
257+
}
258+
259+
@Override
260+
public boolean isConnected() {
261+
return delegate.isConnected();
262+
}
263+
264+
@Override
265+
public boolean isBound() {
266+
return delegate.isBound();
267+
}
268+
269+
@Override
270+
public boolean isClosed() {
271+
return delegate.isClosed();
272+
}
273+
274+
@Override
275+
public boolean isInputShutdown() {
276+
return delegate.isInputShutdown();
277+
}
278+
279+
@Override
280+
public boolean isOutputShutdown() {
281+
return delegate.isOutputShutdown();
282+
}
283+
284+
@Override
285+
public void setPerformancePreferences(int connectionTime, int latency, int bandwidth) {
286+
delegate.setPerformancePreferences(connectionTime, latency, bandwidth);
287+
}
288+
289+
@Override
290+
public String toString() {
291+
return "FTPSocket{" + delegate + "}";
292+
}
293+
}

0 commit comments

Comments
 (0)