Skip to content

Commit 0fa741d

Browse files
committed
Make data transfer timeout tracking independent of streams controlled by
the user stalling. Could address #256
1 parent e025e1d commit 0fa741d

8 files changed

Lines changed: 403 additions & 14 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ client.ftp.verbose = true
6767

6868
`new Client(timeout, options)`
6969

70-
Create a client instance. Configure it with a timeout in milliseconds that will be used for any connection made. Use 0 to disable timeouts, default is 30 seconds. Options are:
70+
Create a client instance. Configure it with a timeout in milliseconds that will be used for any connection made. Use 0 to disable timeouts, default is 30 seconds.
71+
72+
The timeout applies to the server: a transfer fails if the server stops making progress for that long. It doesn't limit how long your own streams may take. A download piped into a slow destination, or an upload fed by a slow source, can hold up a transfer for as long as it needs to without running into a timeout. If you want to limit that as well, do it in your own code.
73+
74+
Options are:
7175

7276
- `allowSeparateTransferHost (boolean)`, the FTP spec makes it possible for a server to tell the client to use a different IP address for file transfers than for the initial control connection. This is a potential vector for FTP bounce attacks, so by default this is set to `false` and the library will throw an error if a server tries to redirect transfers to a different host. Set this to `true` only if you are connecting to a server that legitimately requires it.
7377

src/TransferWatchdog.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Socket } from "net"
2+
3+
/** A transfer either sends data to or receives data from the data connection. */
4+
export type TransferDirection = "upload" | "download"
5+
6+
/** How long to wait between two checks of a transfer at most, in milliseconds. */
7+
const maxCheckIntervalMs = 500
8+
9+
/**
10+
* How many checks a timeout is split into, at least. A transfer is only reported as stalled if
11+
* every single one of them found the connection idle. Deciding on a single check would make brief
12+
* moments where a transfer looks idle without being stalled matter, for example just after a
13+
* destination signalled that it can accept data again but before the first bytes arrived.
14+
*
15+
* Splitting into n checks means such a moment has to last about (n-1)/n of the timeout to be
16+
* mistaken for a stall, so the value matters much less than it being greater than one. It only
17+
* has an effect on timeouts below `minChecksPerTimeout * maxCheckIntervalMs` anyway, longer ones
18+
* are split into more checks by the interval limit alone.
19+
*/
20+
const minChecksPerTimeout = 4
21+
22+
/**
23+
* Watches a data connection during a transfer and reports it as stalled if the server stopped
24+
* making progress.
25+
*
26+
* This replaces a plain inactivity timeout on the data socket. Such a timeout can't tell apart
27+
* "the server stopped sending" from "our own source or destination isn't ready yet". A slow local
28+
* stream is not an error: A download piped into a decompressor, or an upload fed by a stream that
29+
* computes its data, can legitimately leave the connection idle for minutes. Timing out on that
30+
* kills a healthy transfer and truncates the data the destination already received.
31+
*
32+
* Only time spent waiting for the server counts towards the timeout, see `isWaitingForServer`.
33+
*/
34+
export class TransferWatchdog {
35+
36+
protected timer: NodeJS.Timeout | undefined = undefined
37+
38+
/**
39+
* Start watching a transfer. Calls `onStall` if the server hasn't made progress for
40+
* `timeout` milliseconds. A timeout of 0 disables the watchdog.
41+
*/
42+
start(socket: Socket, direction: TransferDirection, timeout: number, onStall: () => void) {
43+
this.stop()
44+
if (timeout <= 0) {
45+
return
46+
}
47+
const intervalMs = Math.max(1, Math.min(Math.floor(timeout / minChecksPerTimeout), maxCheckIntervalMs))
48+
let lastBytes = countBytes(socket)
49+
let idleMs = 0
50+
this.timer = setInterval(() => {
51+
const bytes = countBytes(socket)
52+
const madeProgress = bytes !== lastBytes
53+
lastBytes = bytes
54+
if (madeProgress || !isWaitingForServer(socket, direction)) {
55+
idleMs = 0
56+
return
57+
}
58+
// Count checks instead of measuring elapsed time: a blocked event loop delays our
59+
// checks just as much as it delays reading from the socket, and that's not something
60+
// the server should be blamed for.
61+
idleMs += intervalMs
62+
if (idleMs >= timeout) {
63+
this.stop()
64+
onStall()
65+
}
66+
}, intervalMs)
67+
// Don't keep the process alive just to watch a transfer.
68+
this.timer.unref()
69+
}
70+
71+
/**
72+
* Stop watching. Safe to call at any time, also if no transfer is being watched.
73+
*/
74+
stop() {
75+
if (this.timer) {
76+
clearInterval(this.timer)
77+
this.timer = undefined
78+
}
79+
}
80+
}
81+
82+
function countBytes(socket: Socket): number {
83+
return socket.bytesRead + socket.bytesWritten
84+
}
85+
86+
/**
87+
* Returns true if the transfer can only continue once the server acts. When downloading, that's
88+
* the case as long as we're ready to receive: if the socket is paused, our destination applied
89+
* backpressure and the server may well be waiting for us. When uploading, it's the case if we
90+
* still have data queued that the server isn't accepting: an empty queue means we're waiting for
91+
* our own source instead.
92+
*
93+
* `isPaused()` only reports backpressure for a socket that is being piped, which is what
94+
* `downloadTo` in transfer.ts does. Should that ever change to reading the socket directly, e.g.
95+
* by iterating over it, this would report a transfer as stalled while it's waiting for us.
96+
*/
97+
function isWaitingForServer(socket: Socket, direction: TransferDirection): boolean {
98+
return direction === "download" ? !socket.isPaused() : socket.writableLength > 0
99+
}

src/transfer.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { EventEmitter } from "events"
2+
import { Socket } from "net"
23
import { Readable, Writable, pipeline } from "stream"
34
import { TLSSocket, connect as connectTLS } from "tls"
45
import { FTPContext, FTPResponse, TaskResolver } from "./FtpContext"
56
import { ProgressTracker, ProgressType } from "./ProgressTracker"
7+
import { TransferWatchdog } from "./TransferWatchdog"
68
import { describeAddress, describeTLS, ipIsPrivateV4Address, isLoopback } from "./netUtils"
79
import { positiveCompletion, positiveIntermediate } from "./parseControlResponse"
810

@@ -109,7 +111,8 @@ export function parsePasvResponse(message: string): { host: string, port: number
109111

110112
export function connectForPassiveTransfer(host: string, port: number, ftp: FTPContext): Promise<void> {
111113
return new Promise((resolve, reject) => {
112-
let socket = ftp._newSocket()
114+
const rawSocket = ftp._newSocket()
115+
let socket: Socket | TLSSocket = rawSocket
113116
const handleConnErr = function(err: Error) {
114117
err.message = "Can't open data connection in passive mode: " + err.message
115118
reject(err)
@@ -123,8 +126,8 @@ export function connectForPassiveTransfer(host: string, port: number, ftp: FTPCo
123126
socket.on("timeout", handleTimeout)
124127
socket.connect({ port, host, family: ftp.ipFamily}, () => {
125128
if (ftp.socket instanceof TLSSocket) {
126-
socket = connectTLS(Object.assign({}, ftp.tlsOptions, {
127-
socket,
129+
const tlsSocket = connectTLS(Object.assign({}, ftp.tlsOptions, {
130+
socket: rawSocket,
128131
// Reuse the TLS session negotiated earlier when the control connection
129132
// was upgraded. Servers expect this because it provides additional
130133
// security: If a completely new session would be negotiated, a hacker
@@ -135,7 +138,8 @@ export function connectForPassiveTransfer(host: string, port: number, ftp: FTPCo
135138
// When the server issues a new session ticket after this data connection's
136139
// TLS handshake (TLS 1.3 single-use tickets), capture it so the next data
137140
// connection can present a fresh ticket and resume successfully.
138-
socket.on("session", session => { ftp.tlsSessionStore = session })
141+
tlsSocket.on("session", session => { ftp.tlsSessionStore = session })
142+
socket = tlsSocket
139143
// It's the responsibility of the transfer task to wait until the
140144
// TLS socket issued the event 'secureConnect'. We can't do this
141145
// here because some servers will start upgrading after the
@@ -144,6 +148,11 @@ export function connectForPassiveTransfer(host: string, port: number, ftp: FTPCo
144148
// is ready. But for upload this has to be taken into account,
145149
// see the details in the upload() function below.
146150
}
151+
// Disable the timeout that was guarding the connection attempt. This has to happen on
152+
// the socket it was set on: when using TLS, `socket` is by now a wrapper around that
153+
// socket, and a timeout left running underneath would destroy the data connection
154+
// during a transfer that is idle for a legitimate reason.
155+
rawSocket.setTimeout(0)
147156
// Let the FTPContext listen to errors from now on, remove local handler.
148157
socket.removeListener("error", handleConnErr)
149158
socket.removeListener("timeout", handleTimeout)
@@ -165,6 +174,7 @@ class TransferResolver {
165174

166175
protected response: FTPResponse | undefined = undefined
167176
protected dataTransferDone = false
177+
protected readonly watchdog = new TransferWatchdog()
168178

169179
/**
170180
* Instantiate a TransferResolver
@@ -178,31 +188,34 @@ class TransferResolver {
178188
* @param type - Type of transfer, usually "upload" or "download".
179189
*/
180190
onDataStart(name: string, type: ProgressType) {
181-
// Let the data socket be in charge of tracking timeouts during transfer.
191+
// Let the data connection be in charge of tracking timeouts during transfer.
182192
// The control socket sits idle during this time anyway and might provoke
183193
// a timeout unnecessarily. The control connection will take care
184194
// of timeouts again once data transfer is complete or failed.
185195
if (this.ftp.dataSocket === undefined) {
186196
throw new Error("Data transfer should start but there is no data connection.")
187197
}
188198
this.ftp.socket.setTimeout(0)
189-
this.ftp.dataSocket.setTimeout(this.ftp.timeout)
199+
// An inactivity timeout on the data socket would also fire while a slow local source or
200+
// destination is holding up an otherwise healthy transfer. Watch the transfer instead.
201+
this.ftp.dataSocket.setTimeout(0)
202+
this.watchdog.start(this.ftp.dataSocket, type === "upload" ? "upload" : "download", this.ftp.timeout, () => {
203+
this.ftp.closeWithError(new Error("Timeout (data socket)"))
204+
})
190205
this.progress.start(this.ftp.dataSocket, name, type)
191206
}
192207

193208
/**
194209
* The data connection has finished the transfer.
195210
*/
196211
onDataDone(task: TaskResolver) {
212+
this.watchdog.stop()
197213
this.progress.updateAndStop()
198214
// Hand-over timeout tracking back to the control connection. It's possible that
199215
// we don't receive the response over the control connection that the transfer is
200216
// done. In this case, we want to correctly associate the resulting timeout with
201217
// the control connection.
202218
this.ftp.socket.setTimeout(this.ftp.timeout)
203-
if (this.ftp.dataSocket) {
204-
this.ftp.dataSocket.setTimeout(0)
205-
}
206219
this.dataTransferDone = true
207220
this.tryResolve(task)
208221
}
@@ -219,6 +232,7 @@ class TransferResolver {
219232
* An error has been reported and the task should be rejected.
220233
*/
221234
onError(task: TaskResolver, err: Error) {
235+
this.watchdog.stop()
222236
this.progress.updateAndStop()
223237
this.ftp.socket.setTimeout(this.ftp.timeout)
224238
this.ftp.dataSocket = undefined
@@ -324,6 +338,10 @@ export function downloadTo(destination: Writable, config: TransferConfig): Promi
324338
}
325339
config.ftp.log(`Downloading from ${describeAddress(dataSocket)} (${describeTLS(dataSocket)})`)
326340
resolver.onDataStart(config.remotePath, config.type)
341+
// Keep piping the data connection: TransferWatchdog recognizes a destination that
342+
// can't keep up by the socket being paused, and only piping does that. Consuming the
343+
// socket in another way, e.g. by iterating over it, makes the watchdog report a
344+
// transfer as stalled while it's in fact waiting for us.
327345
pipeline(dataSocket, destination, err => {
328346
if (err) {
329347
resolver.onError(task, err)

test/MockFtpServer.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ const DEFAULT_HANDLERS = {
88
quit: () => "200 Bye"
99
}
1010

11+
/**
12+
* A client that goes away mid-conversation is a normal thing for a server to see, and several
13+
* tests provoke exactly that. Without this, writing to such a connection throws EPIPE/ECONNRESET
14+
* as an uncaught exception and fails whichever test happens to be running.
15+
*/
16+
function ignoreErrors(conn) {
17+
conn.on("error", () => {})
18+
}
19+
1120
module.exports = class MockFtpServer {
1221
constructor() {
1322
this.didOpenDataConn = () => {}
@@ -22,6 +31,7 @@ module.exports = class MockFtpServer {
2231
this.ctrlConn = conn
2332
this.connections.push(conn)
2433
conn.allowHalfOpen = true
34+
ignoreErrors(conn)
2535
conn.write(`200 Welcome${NEWLINE}`)
2636
conn.on("data", data => {
2737
const command = data.toString().trim()
@@ -41,6 +51,7 @@ module.exports = class MockFtpServer {
4151
this.dataServer = net.createServer(conn => {
4252
this.dataConn = conn
4353
this.connections.push(conn)
54+
ignoreErrors(conn)
4455
this.didOpenDataConn()
4556
const bufs = []
4657
conn.on("data", data => {
@@ -58,6 +69,9 @@ module.exports = class MockFtpServer {
5869
}
5970

6071
writeCtrl(payload) {
72+
if (!this.ctrlConn || this.ctrlConn.destroyed) {
73+
return
74+
}
6175
this.ctrlConn.write(`${payload}${NEWLINE}`)
6276
}
6377

test/downloadSpec.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const fs = require("fs");
88

99
const FILENAME = "file.txt"
1010
const TIMEOUT = 1000
11+
// Used where a test has to wait for a timeout to happen, or to not happen.
12+
const SHORT_TIMEOUT = 100
1113
const EMPTY_TEXT = ""
1214
const SHORT_TEXT = "Short"
1315
const MEDIUM_TEXT = "s".repeat(45017) // https://github.com/patrickjuchli/basic-ftp/issues/205
@@ -79,6 +81,50 @@ describe("Download to stream", () => {
7981
assert.deepEqual(buf.getText("utf-8"), MEDIUM_TEXT)
8082
})
8183

84+
// A destination that is slow to accept data is not a broken connection. Timing out on it
85+
// aborts a healthy transfer and truncates whatever the destination received so far.
86+
it("doesn't time out while a slow destination is holding up the transfer", async () => {
87+
payload = "s".repeat(1000 * 1000)
88+
client.ftp.timeout = SHORT_TIMEOUT
89+
let received = 0
90+
let blocking = false
91+
let bytesReadWhileBlocked = 0
92+
const destination = new Writable({
93+
highWaterMark: 1,
94+
write(chunk, enc, cb) {
95+
received += chunk.length
96+
if (blocking) {
97+
cb()
98+
return
99+
}
100+
blocking = true
101+
setTimeout(() => {
102+
bytesReadWhileBlocked = client.ftp.dataSocket.bytesRead
103+
cb()
104+
}, 5 * SHORT_TIMEOUT)
105+
}
106+
})
107+
await client.downloadTo(destination, FILENAME)
108+
assert.strictEqual(received, payload.length, "received all data")
109+
assert.ok(bytesReadWhileBlocked < payload.length,
110+
`transfer was really held up, read ${bytesReadWhileBlocked} of ${payload.length} bytes while blocked`)
111+
})
112+
113+
it("times out if the server stops sending", async () => {
114+
client.ftp.timeout = SHORT_TIMEOUT
115+
server.addHandlers({
116+
"pasv": () => `227 Entering Passive Mode (${server.dataAddressForPasvResponse})`,
117+
// Send something, then go silent without ever closing the data connection.
118+
"retr": () => {
119+
setTimeout(() => server.dataConn.write("the beginning..."))
120+
return "150 Ready to download"
121+
}
122+
})
123+
return assert.rejects(() => client.downloadTo(new StringWriter(), FILENAME), {
124+
message: "Timeout (data socket)"
125+
})
126+
})
127+
82128
it("handles late destination stream error", async () => {
83129
server.addHandlers({
84130
"pasv": () => `227 Entering Passive Mode (${server.dataAddressForPasvResponse})`,
@@ -161,7 +207,32 @@ describe("Download to stream", () => {
161207
dataSocket.destroy(new Error("Error that should be ignored because task has completed successfully"))
162208
})
163209

164-
it.todo("stops tracking timeout after failure")
210+
it("stops tracking timeout after failure", async () => {
211+
client.ftp.timeout = SHORT_TIMEOUT
212+
server.addHandlers({
213+
"pasv": () => `227 Entering Passive Mode (${server.dataAddressForPasvResponse})`,
214+
// Fail while the data connection is transferring.
215+
"retr": () => {
216+
setTimeout(() => {
217+
server.dataConn.write("the beginning...")
218+
server.writeCtrl("500 Something went wrong")
219+
})
220+
return "150 Ready to download"
221+
},
222+
"noop": () => "200 OK"
223+
})
224+
await assert.rejects(() => client.downloadTo(new StringWriter(), FILENAME), {
225+
message: "500 Something went wrong"
226+
})
227+
assert.strictEqual(client.ftp.socket.timeout, 0, "control socket stopped tracking")
228+
// Nothing may be left watching the failed transfer: it would report a timeout later on,
229+
// taking down whatever the client is doing by then.
230+
await client.access({ port: server.ctrlAddress.port, user: "test", password: "test" })
231+
await new Promise(resolve => setTimeout(resolve, 3 * SHORT_TIMEOUT))
232+
assert.strictEqual(client.closed, false, "client still connected after being idle")
233+
await client.send("NOOP")
234+
})
235+
165236
it.todo("can get a directory listing")
166237
it.todo("uses control host IP if suggested data connection IP using PASV is private")
167238
it.todo("can download using TLS")

0 commit comments

Comments
 (0)