From ac12f7d1c1c8e31c4800a734dd5f9894f1dee066 Mon Sep 17 00:00:00 2001 From: srgg Date: Tue, 11 Aug 2026 16:29:42 -0600 Subject: [PATCH 1/5] fix: report every transfer outcome truthfully, and always report the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app pairs FTP_DOWNLOAD_START/FTP_UPLOAD_START with the terminal callback to release what it acquired for a transfer — a radio boost, a paused advertisement, a progress UI. Several endings never delivered that pairing, and several delivered the wrong one. Missing terminal callback: - closeTransfer() emitted it only inside `deltaT > 0 && bytesTransfered > 0`, a condition written to guard the throughput division. An empty file, or one that finished inside a single millisecond, ended with no notification at all. - dataConnected() answered 426 and closed the stage without any callback. - doStore()'s out-of-space path answered 552 and closed the file by hand: no callback, no dir close, no restart-position reset. Success reported for a failure — the client is told 226 and the app is told FTP_TRANSFER_STOP: - doRetrieve(): a zero-length socket write, and a peer that closed the data connection with bytes still to send (a truncated download). - doStore(): a peer that stayed connected but sent nothing for 5 s. - doRetrieve()'s REST seek failure additionally answered twice, 450 then 226. All of these now end through abortTransfer(), which closes the file and the dir, fires FTP_TRANSFER_ERROR, resets the restart position and replies once. It takes an optional reply so a caller with a more specific code than 426 keeps it — used by the 450 and 552 paths above. Separately, doRetrieve() dropped data on a short write: file.read() had already advanced the cursor by the full block, so bytes the socket did not accept were never sent and the client received a file with a hole in it and no error. The cursor is now rewound to the first unsent byte. (cherry picked from commit 6ca6f9cd5d8649751d6e88fd97ac80b1a7d42035) --- FtpServer.cpp | 50 ++++++++++++++++++++++++++++++-------------------- FtpServer.h | 5 ++++- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/FtpServer.cpp b/FtpServer.cpp index 65a2115..a444a7c 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -1393,9 +1393,7 @@ bool FtpServer::dataConnected() { if( data.connected()) return true; - data.stop(); - client.println(F("426 Data connection closed. Transfer aborted") ); - transferStage = FTP_Close; + abortTransfer(F("426 Data connection closed. Transfer aborted")); return false; } @@ -1496,8 +1494,8 @@ bool FtpServer::doRetrieve() // Handle resume if REST was used if (restartPos > 0) { if (!file.seek(restartPos)) { - client.println(F("450 Cannot seek to restart position.")); - closeTransfer(); + DEBUG_PRINTLN(F("ERROR: cannot seek to restart position")); + abortTransfer(F("450 Cannot seek to restart position.")); return false; } bytesTransfered = restartPos; // Adjust the transferred bytes @@ -1534,7 +1532,7 @@ bool FtpServer::doRetrieve() if (written <= 0) { DEBUG_PRINTLN(F("ERROR: data.write returned <= 0")); - closeTransfer(); + abortTransfer(); return false; } @@ -1550,6 +1548,14 @@ bool FtpServer::doRetrieve() if (more > 0) written += more; } + // file.read() advanced the cursor by the full nb, so whatever went unsent must be re-read + // next round — otherwise those bytes vanish from the middle of the stream, silently. + if (written < nb && !file.seek(bytesTransfered + written)) { + DEBUG_PRINTLN(F("ERROR: cannot rewind after a short write")); + abortTransfer(); // the unsent bytes are unrecoverable — this is not a completed transfer + return false; + } + // Try to flush the socket where available (ESP-specific) #if defined(ESP8266) || defined(ESP32) data.flush(); @@ -1566,9 +1572,10 @@ bool FtpServer::doRetrieve() DEBUG_PRINT(F("DATA CONNECTED AFTER WRITE -> ")); DEBUG_PRINTLN(data.connected() ? 1 : 0); + // Reachable only with bytes still to send, so the peer left mid-file: the transfer failed. if (!data.connected()) { DEBUG_PRINTLN(F("Data socket closed by peer after write")); - closeTransfer(); + abortTransfer(); return false; } @@ -1607,8 +1614,9 @@ bool FtpServer::doStore() DEBUG_PRINT(F("No data received after ")); DEBUG_PRINT(waited); DEBUG_PRINTLN(F(" ms")); - // Decide to close transfer to avoid infinite loop and client timeout - closeTransfer(); + // A peer that finished a STOR closes the data connection; one still connected and + // silent has stalled, so answering 226 would call an unfinished upload complete. + abortTransfer(); return false; } // else continue and read available data below @@ -1662,9 +1670,8 @@ bool FtpServer::doStore() if( nb < 0 || rc == nb ) { return true; } - client.println(F("552 Probably insufficient storage space") ); - file.close(); - data.stop(); + + abortTransfer(F("552 Probably insufficient storage space")); return false; } @@ -2195,16 +2202,16 @@ void FtpServer::closeTransfer() data.stop(); + // Fires on every completed transfer, including an empty or sub-millisecond one. + if (FtpServer::_transferCallback) { + FtpServer::_transferCallback(FTP_TRANSFER_STOP, getFileName(&file).c_str(), bytesTransfered); + } + if( deltaT > 0 && bytesTransfered > 0 ) { DEBUG_PRINT( F(" Transfer completed in ") ); DEBUG_PRINT( deltaT ); DEBUG_PRINTLN( F(" ms, ") ); DEBUG_PRINT( bytesTransfered / deltaT ); DEBUG_PRINTLN( F(" kbytes/s") ); - if (FtpServer::_transferCallback) { - FtpServer::_transferCallback(FTP_TRANSFER_STOP, getFileName(&file).c_str(), bytesTransfered); - } - - client.println(F("226-File successfully transferred") ); client.print( F("226 ") ); client.print( deltaT ); client.print( F(" ms, ") ); client.print( bytesTransfered / deltaT ); client.println( F(" kbytes/s") ); @@ -2213,11 +2220,14 @@ void FtpServer::closeTransfer() client.println(F("226 File successfully transferred") ); } -void FtpServer::abortTransfer() +void FtpServer::abortTransfer(const __FlashStringHelper* reply) { if( transferStage != FTP_Close ) { - if (FtpServer::_transferCallback) { + // A listing has no file and no byte count of its own: reporting one would hand the + // application the name and total of whatever transfer ran before it. + const bool sending_file = ( transferStage == FTP_Retrieve || transferStage == FTP_Store ); + if (sending_file && FtpServer::_transferCallback) { FtpServer::_transferCallback(FTP_TRANSFER_ERROR, getFileName(&file).c_str(), bytesTransfered); } @@ -2225,7 +2235,7 @@ void FtpServer::abortTransfer() #if STORAGE_TYPE != STORAGE_SPIFFS && STORAGE_TYPE != STORAGE_LITTLEFS && STORAGE_TYPE != STORAGE_SEEED_SD dir.close(); #endif - client.println(F("426 Transfer aborted") ); + client.println( reply ? reply : F("426 Transfer aborted") ); DEBUG_PRINTLN( F(" Transfer aborted!") ); transferStage = FTP_Close; diff --git a/FtpServer.h b/FtpServer.h index c916866..6f8da4f 100644 --- a/FtpServer.h +++ b/FtpServer.h @@ -586,7 +586,10 @@ class FtpServer bool doList(); bool doMlsd(); void closeTransfer(); - void abortTransfer(); + // Ends a transfer as FAILED: closes the file and the directory, replies exactly once — + // `reply` replaces the default "426 Transfer aborted" — and fires FTP_TRANSFER_ERROR for the + // stages that carry a file, so a listing that loses its data connection reports no transfer. + void abortTransfer(const __FlashStringHelper* reply = nullptr); bool makePath( char * fullName, char * param = nullptr ); bool makeExistsPath( char * path, char * param = nullptr ); bool openDir( FTP_DIR * pdir ); From 54796f850e5ea30cfc47c4399866a2f04c884a9c Mon Sep 17 00:00:00 2001 From: srgg Date: Tue, 11 Aug 2026 16:30:29 -0600 Subject: [PATCH 2/5] feat: ride out a peer whose TCP window shuts, instead of ending the transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-length socket write ended a download. It does not mean the peer is gone: the network client returns zero once its own retry budget expires, which on a memory-constrained board happens whenever the WiFi driver momentarily cannot allocate a transmit buffer. The window reopens seconds later. Downloads were being torn down for a condition that clears by itself — on the board this was first measured on, a 2.9 MB file died after 32 KB. doRetrieve() now treats a zero-length write as no progress rather than an ending: the file cursor is rewound so the same block is retried, the byte count and the download-progress callback stay put, and the transfer continues. Only rounds that actually sent something push the idle deadline out, so a peer that never comes back is ended by that deadline instead of running forever. The deadline could not do that job before. It was the tail of the if/else chain in handleFTP(), and a running transfer always took its own branch first — so it was unreachable during exactly the case that now needs bounding. It is now a check of its own, scoped to an idle command connection or a RETR: the other transfer types never refresh the deadline, and bounding them here would kill them mid-progress. (cherry picked from commit d7c5cf0a2fbdb97dc6cd75f9899dafbe65637d13) --- FtpServer.cpp | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/FtpServer.cpp b/FtpServer.cpp index a444a7c..cca088d 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -444,10 +444,23 @@ uint8_t FtpServer::handleFTP() { transferStage = FTP_Close; } - } else if (cmdStage > FTP_Client + } + + // Out of the chain above, whose tail this was: a running transfer always took its own + // branch, so the deadline was never reached — and doRetrieve() now waits a stalled peer + // out instead of aborting, leaving nothing else to end it. RETR refreshes this deadline + // as it sends; the other types never do, so bounding them would kill them mid-progress. + const bool in_retrieve = (transferStage == FTP_Retrieve); + if (cmdStage > FTP_Client && (transferStage == FTP_Close || in_retrieve) && !((int32_t) (millisEndConnection - millis()) > 0)) { - DEBUG_PRINTLN(F("530 Timeout")); - client.println(F("530 Timeout")); + DEBUG_PRINTLN(F("Timeout")); + if (in_retrieve) { + // NOT closeTransfer(): that answers 226. abortTransfer() replies 426 and fires + // FTP_TRANSFER_ERROR, which releases what the app took. + abortTransfer(); + } else { + client.println(F("530 Timeout")); + } millisDelay = millis() + 200; // delay of 200 ms cmdStage = FTP_Stop; } @@ -1530,14 +1543,8 @@ bool FtpServer::doRetrieve() DEBUG_PRINT(F("WRITTEN --> ")); DEBUG_PRINTLN(written); - if (written <= 0) { - DEBUG_PRINTLN(F("ERROR: data.write returned <= 0")); - abortTransfer(); - return false; - } - // If partial write, try to send the remainder (best-effort) - if (written < nb) { + if (written > 0 && written < nb) { int16_t remaining = nb - written; DEBUG_PRINT(F("Partial write, attempting remainder -> ")); DEBUG_PRINTLN(remaining); @@ -1581,7 +1588,14 @@ bool FtpServer::doRetrieve() bytesTransfered += written; - if (FtpServer::_transferCallback) { + // Progress pushes the idle deadline out; a round that sent nothing deliberately does not — + // a zero write is often a transient shut window, and that deadline ends a peer really gone. + if (written > 0) { + millisEndConnection = millis() + 1000L * FTP_TIME_OUT; + } + + // Invoke callback on real progress: a stalled round must not look like a moving one to a watching app. + if (written > 0 && FtpServer::_transferCallback) { FtpServer::_transferCallback(FTP_DOWNLOAD, getFileName(&file).c_str(), bytesTransfered); } From 20744d83a06b8adcaced7738de1bd9fdefecc5d8 Mon Sep 17 00:00:00 2001 From: srgg Date: Sat, 15 Aug 2026 00:38:00 -0600 Subject: [PATCH 3/5] fix: render a listing entry once, resume it if the peer takes part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry went out as up to nine separate print() calls. A window that shuts mid-entry left half a line in the stream with the directory cursor already past it, so the rest of the listing was garbage. Each call also spends the socket's whole write budget again — ten seconds on ESP32 Arduino — so a peer that stopped reading held one handleFTP() call for ninety seconds. An entry is now rendered into a buffer sized from the limits this header already declares, and written once, the remainder pending until the peer takes it, as doRetrieve() does for a short write. A line too long keeps its CRLF, or it would merge into the next entry. Both paths write through one accountant, so neither sends without moving the deadline. --- FtpServer.cpp | 296 +++++++++++++++++++++----------------------------- FtpServer.h | 24 ++++ 2 files changed, 146 insertions(+), 174 deletions(-) diff --git a/FtpServer.cpp b/FtpServer.cpp index cca088d..f917201 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -880,6 +880,10 @@ bool FtpServer::processCommand() DEBUG_PRINTLN(F("Dir opened!!")); nbMatch = 0; + // Reset here rather than where a listing ends: a client that drops the data + // connection mid-entry ends one through neither closeTransfer() nor abortTransfer(), + // and the entry left pending would open the next listing. + listLineLen = listLineSent = 0; if( CommandIs( "LIST" )) transferStage = FTP_List; else if( CommandIs( "NLST" )) @@ -1536,7 +1540,7 @@ bool FtpServer::doRetrieve() { // write() may not send everything in one call on some clients; capture return int32_t written = 0; - written = data.write( buf, nb ); + written = writeData( (const uint8_t*) buf, nb ); DEBUG_PRINT(F("NB --> ")); DEBUG_PRINTLN(nb); @@ -1549,7 +1553,7 @@ bool FtpServer::doRetrieve() DEBUG_PRINT(F("Partial write, attempting remainder -> ")); DEBUG_PRINTLN(remaining); const uint8_t* p = (const uint8_t*)buf + written; - int32_t more = data.write(p, remaining); + int32_t more = writeData(p, remaining); DEBUG_PRINT(F("MORE WRITTEN -> ")); DEBUG_PRINTLN(more); if (more > 0) written += more; @@ -1588,12 +1592,6 @@ bool FtpServer::doRetrieve() bytesTransfered += written; - // Progress pushes the idle deadline out; a round that sent nothing deliberately does not — - // a zero write is often a transient shut window, and that deadline ends a peer really gone. - if (written > 0) { - millisEndConnection = millis() + 1000L * FTP_TIME_OUT; - } - // Invoke callback on real progress: a stalled round must not look like a moving one to a watching app. if (written > 0 && FtpServer::_transferCallback) { FtpServer::_transferCallback(FTP_DOWNLOAD, getFileName(&file).c_str(), bytesTransfered); @@ -1689,59 +1687,17 @@ bool FtpServer::doStore() return false; } -void generateFileLine(FTP_CLIENT_NETWORK_CLASS* data, bool isDirectory, const char* fn, long fz, const char* time, const char* user, bool writeFilename = true) { - if( isDirectory ) { - // data->print( F("+/,\t") ); - // DEBUG_PRINT(F("+/,\t")); - - data->print( F("drwxrwsr-x\t2\t")); - data->print( user ); - data->print( F("\t") ); - data->print( long( 4096 ) ); - data->print( F("\t") ); - - DEBUG_PRINT( F("drwxrwsr-x\t2\t") ); - DEBUG_PRINT( user ); - DEBUG_PRINT( F("\t") ); - - DEBUG_PRINT( long( 4096 ) ); - DEBUG_PRINT( F("\t") ); - - data->print(time); - DEBUG_PRINT(time); - - data->print( F("\t") ); - if (writeFilename) data->println( fn ); - - DEBUG_PRINT( F("\t") ); - if (writeFilename) DEBUG_PRINTLN( fn ); - - } else { -// data.print( F("+r,s") ); -// DEBUG_PRINT(F("+r,s")); - - data->print( F("-rw-rw-r--\t1\t") ); - data->print( user ); - data->print( F("\t") ); - data->print( fz ); - data->print( F("\t") ); - - DEBUG_PRINT( F("-rw-rw-r--\t1\t") ); - DEBUG_PRINT( user ); - DEBUG_PRINT( F("\t") ); - DEBUG_PRINT( fz ); - DEBUG_PRINT( F("\t") ); - - data->print(time); - DEBUG_PRINT(time); - - data->print( F("\t") ); - if (writeFilename) data->println( fn ); - - DEBUG_PRINT( F("\t") ); - if (writeFilename) DEBUG_PRINTLN( fn ); - } - +// Renders one LIST entry into `out` and returns the length the whole line needs, which may +// exceed `outSize` — the caller decides what a line too long for its buffer becomes, and it +// cannot decide that from a length already clamped to the buffer. Building the line before +// any of it is sent is what lets a caller resume a partial write: nine separate print() +// calls could not, and each of them burns the socket's whole write budget again when the +// peer's window is shut. +size_t generateFileLine(char* out, size_t outSize, bool isDirectory, const char* fn, long fz, const char* time, const char* user) { + const int n = snprintf(out, outSize, "%s\t%s\t%ld\t%s\t%s\r\n", + isDirectory ? "drwxrwsr-x\t2" : "-rw-rw-r--\t1", + user, isDirectory ? 4096L : fz, time, fn); + return n < 0 ? 0 : (size_t) n; } #if defined(ESP32) || defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) @@ -1793,11 +1749,69 @@ String makeDateTimeStrList(time_t ft, bool dateContracted = false) } // https://files.stairways.com/other/ftp-list-specs-info.txt -void generateFileLine(FTP_CLIENT_NETWORK_CLASS* data, bool isDirectory, const char* fn, long fz, time_t time, const char* user, bool writeFilename = true) { - generateFileLine(data, isDirectory, fn, fz, makeDateTimeStrList(time).c_str(), user, writeFilename); +size_t generateFileLine(char* out, size_t outSize, bool isDirectory, const char* fn, long fz, time_t time, const char* user) { + return generateFileLine(out, outSize, isDirectory, fn, fz, makeDateTimeStrList(time).c_str(), user); } #endif +// Renders one listing entry into listLine. Callers hand over the values their storage +// backend exposes; the wire format is the backend-independent part. +// Takes what snprintf() reported and returns the length to send. A line longer than the buffer +// is truncated by snprintf without its CRLF, and a listing entry with no line ending merges into +// the next one at the client, so the ending is restored over the last two bytes. +uint16_t FtpServer::finishListLine(int rendered) +{ + listLineSent = 0; + if( rendered <= 0 ) return 0; + if( (size_t) rendered < sizeof( listLine )) return (uint16_t) rendered; + listLine[ sizeof( listLine ) - 3 ] = '\r'; + listLine[ sizeof( listLine ) - 2 ] = '\n'; + listLine[ sizeof( listLine ) - 1 ] = '\0'; + return (uint16_t) ( sizeof( listLine ) - 1 ); +} + +// Renders one listing entry into listLine. Callers hand over the values their storage backend +// exposes; the wire format is the backend-independent part. +void FtpServer::buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, const char* time) +{ + const int n = isNlst ? snprintf( listLine, sizeof( listLine ), "%s\r\n", fn ) + : (int) generateFileLine( listLine, sizeof( listLine ), isDirectory, fn, fz, time, this->user ); + listLineLen = finishListLine( n ); + DEBUG_PRINT( listLine ); +} + +void FtpServer::buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, time_t time) +{ + buildListLine( isNlst, isDirectory, fn, fz, makeDateTimeStrList( time ).c_str()); +} + +void FtpServer::buildMlsdLine(bool isDirectory, const char* dtStr, long fz, const char* fn) +{ + const int n = snprintf( listLine, sizeof( listLine ), "Type=%s;Modify=%s;Size=%ld; %s\r\n", + isDirectory ? "dir" : "file", dtStr, fz, fn ); + listLineLen = finishListLine( n ); + DEBUG_PRINT( listLine ); +} + +size_t FtpServer::writeData(const uint8_t* p, size_t len) +{ + const size_t n = data.write( p, len ); + if( n > 0 ) millisEndConnection = millis() + 1000L * FTP_TIME_OUT; + return n; +} + +bool FtpServer::sendListLine() +{ + if( listLineLen == 0 ) return true; + listLineSent += (uint16_t) writeData((const uint8_t*) listLine + listLineSent, + listLineLen - listLineSent ); + if( listLineSent < listLineLen ) return false; + listLineLen = 0; + listLineSent = 0; + nbMatch ++; + return true; +} + bool FtpServer::doList() { if( ! dataConnected()) @@ -1808,6 +1822,10 @@ bool FtpServer::doList() return false; } + // An entry already rendered owns this round: its directory slot is gone, so the + // remainder has to go out before the cursor may move again. + if( listLineLen > 0 && ! sendListLine()) return true; + // Determine if current transfer is NLST (name list) so we only send filenames bool isNlst = (transferStage == FTP_Nlst); #if STORAGE_TYPE == STORAGE_SPIFFS @@ -1824,12 +1842,7 @@ bool FtpServer::doList() long fz = long( dir.fileSize()); if (fn[0]=='/') { fn.remove(0, fn.lastIndexOf("/")+1); } time_t time = dir.fileTime(); - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, false, fn.c_str(), fz, time, this->user); - } + buildListLine( isNlst, false, fn.c_str(), fz, time ); #else long fz = long( fileDir.size()); const char* fnC = fileDir.name(); @@ -1841,16 +1854,11 @@ bool FtpServer::doList() } time_t time = fileDir.getLastWrite(); - if (isNlst) { - data.println(fn); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, false, fn, fz, time, this->user); - } + buildListLine( isNlst, false, fn, fz, time ); #endif - nbMatch ++; + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_LITTLEFS || STORAGE_TYPE == STORAGE_SEEED_SD || STORAGE_TYPE == STORAGE_FFAT @@ -1897,21 +1905,14 @@ bool FtpServer::doList() // DEBUG_PRINT( F("\t") ); // DEBUG_PRINTLN( fileDir.name() ); #endif - if (isNlst) { - data.println(fn); - DEBUG_PRINTLN(fn); - } else { - #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) - time_t time = dir.fileTime(); - generateFileLine(&data, dir.isDirectory(), fn, fz, time, this->user); - #elif defined(ESP32) - time_t time = fileDir.getLastWrite(); - generateFileLine(&data, fileDir.isDirectory(), fn, fz, time, this->user); - #else - generateFileLine(&data, fileDir.isDirectory(), fn, fz, "Jan 01 00:00", this->user); - #endif - } - nbMatch ++; + #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) + buildListLine( isNlst, dir.isDirectory(), fn, fz, dir.fileTime()); + #elif defined(ESP32) + buildListLine( isNlst, fileDir.isDirectory(), fn, fz, fileDir.getLastWrite()); + #else + buildListLine( isNlst, fileDir.isDirectory(), fn, fz, "Jan 01 00:00" ); + #endif + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_SD || STORAGE_TYPE == STORAGE_SD_MMC @@ -1924,22 +1925,12 @@ bool FtpServer::doList() #if STORAGE_TYPE == STORAGE_SD_MMC time_t time = fileDir.getLastWrite(); - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), time, this->user); - } + buildListLine( isNlst, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), time ); #else - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), "Jan 01 00:00", this->user); - } + buildListLine( isNlst, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), "Jan 01 00:00" ); #endif - nbMatch ++; + sendListLine(); return true; } @@ -1950,31 +1941,19 @@ bool FtpServer::doList() String fn = dir.fileName(); if (fn[0]=='/') { fn.remove(0, fn.lastIndexOf("/")+1); } - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, dir.isDir(), fn.c_str(), long( dir.fileSize()), "Jan 01 00:00", this->user); - } + buildListLine( isNlst, dir.isDir(), fn.c_str(), long( dir.fileSize()), "Jan 01 00:00" ); - nbMatch ++; + sendListLine(); return true; } #else if( file.openNext( &dir, FTP_FILE_READ_ONLY )) { - // For storages using file.printName, only send name in NLST mode - if (isNlst) { - file.printName(&data); - data.println(); - } else { - generateFileLine(&data, file.isDir(), "", long( fileSize( file )), "Jan 01 00:00", this->user, false); - - file.printName( & data ); - data.println(); - } + char nameBuf[ FTP_CWD_SIZE ]; + file.getName( nameBuf, sizeof( nameBuf )); + buildListLine( isNlst, file.isDir(), nameBuf, long( fileSize( file )), "Jan 01 00:00" ); file.close(); - nbMatch ++; + sendListLine(); return true; } #endif @@ -1999,6 +1978,10 @@ bool FtpServer::doMlsd() DEBUG_PRINTLN(F("Not connected!!")); return false; } + // An entry already rendered owns this round: its directory slot is gone, so the + // remainder has to go out before the cursor may move again. + if( listLineLen > 0 && ! sendListLine()) return true; + DEBUG_PRINTLN(F("Connected!!")); #if STORAGE_TYPE == STORAGE_SPIFFS @@ -2038,21 +2021,8 @@ bool FtpServer::doMlsd() long fz = fileDir.size(); #endif - data.print( F("Type=") ); - - data.print( F("file") ); - data.print( F(";Modify=") ); data.print(dtStr);// data.print( makeDateTimeStr( dtStr, time, time) ); - data.print( F(";Size=") ); data.print( fz ); - data.print( F("; ") ); data.println( fn ); - - DEBUG_PRINT( F("Type=") ); - DEBUG_PRINT( F("file") ); - - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT(dtStr); //DEBUG_PRINT( makeDateTimeStr( dtStr, time, time) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( fz ); - DEBUG_PRINT( F("; ") ); DEBUG_PRINTLN( fn ); - - nbMatch ++; + buildMlsdLine( false, dtStr, fz, fn.c_str()); + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_LITTLEFS || STORAGE_TYPE == STORAGE_SEEED_SD || STORAGE_TYPE == STORAGE_FFAT @@ -2102,14 +2072,13 @@ bool FtpServer::doMlsd() #endif #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) time_t time = dir.fileTime(); - generateFileLine(&data, dir.isDirectory(), fn, fz, time, this->user); + buildListLine( false, dir.isDirectory(), fn, fz, time ); #elif defined(ESP32) - time_t time = fileDir.getLastWrite(); - generateFileLine(&data, fileDir.isDirectory(), fn, fz, time, this->user); + buildListLine( false, fileDir.isDirectory(), fn, fz, fileDir.getLastWrite()); #else - generateFileLine(&data, fileDir.isDirectory(), fn, fz, "Jan 01 00:00", this->user); + buildListLine( false, fileDir.isDirectory(), fn, fz, "Jan 01 00:00" ); #endif - nbMatch ++; + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_SD || STORAGE_TYPE == STORAGE_SD_MMC @@ -2132,21 +2101,8 @@ bool FtpServer::doMlsd() - data.print( F("Type=") ); - - data.print( ( fileDir.isDirectory() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print(dtStr);// data.print( makeDateTimeStr( dtStr, time, time) ); - data.print( F(";Size=") ); data.print( fz ); - data.print( F("; ") ); data.println( fn ); - - DEBUG_PRINT( F("Type=") ); - DEBUG_PRINT( ( fileDir.isDirectory() ? F("dir") : F("file")) ); - - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT(dtStr); //DEBUG_PRINT( makeDateTimeStr( dtStr, time, time) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( fz ); - DEBUG_PRINT( F("; ") ); DEBUG_PRINTLN( fn ); - - nbMatch ++; + buildMlsdLine( fileDir.isDirectory(), dtStr, fz, fn.c_str()); + sendListLine(); return true; } @@ -2154,11 +2110,10 @@ bool FtpServer::doMlsd() if( dir.nextFile()) { char dtStr[ 15 ]; - data.print( F("Type=") ); data.print( ( dir.isDir() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print( makeDateTimeStr( dtStr, dir.fileModDate(), dir.fileModTime()) ); - data.print( F(";Size=") ); data.print( long( dir.fileSize()) ); - data.print( F("; ") ); data.println( dir.fileName() ); - nbMatch ++; + String fn = dir.fileName(); + buildMlsdLine( dir.isDir(), makeDateTimeStr( dtStr, dir.fileModDate(), dir.fileModTime()), + long( dir.fileSize()), fn.c_str()); + sendListLine(); return true; } #else @@ -2171,18 +2126,11 @@ bool FtpServer::doMlsd() DEBUG_PRINTLN(gfmt); if( gfmt ) { - data.print( F("Type=") ); data.print( ( file.isDir() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print( makeDateTimeStr( dtStr, filelwd, filelwt ) ); - data.print( F(";Size=") ); data.print( long( fileSize( file )) ); data.print( F("; ") ); - file.printName( & data ); - data.println(); - - DEBUG_PRINT( F("Type=") ); DEBUG_PRINT( ( file.isDir() ? F("dir") : F("file")) ); - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT( makeDateTimeStr( dtStr, filelwd, filelwt ) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( long( fileSize( file )) ); DEBUG_PRINT( F("; ") ); -// DEBUG_PRINT(file.name()); - DEBUG_PRINTLN(); - nbMatch ++; + char nameBuf[ FTP_CWD_SIZE ]; + file.getName( nameBuf, sizeof( nameBuf )); + buildMlsdLine( file.isDir(), makeDateTimeStr( dtStr, filelwd, filelwt ), + long( fileSize( file )), nameBuf ); + sendListLine(); } file.close(); return gfmt; diff --git a/FtpServer.h b/FtpServer.h index 6f8da4f..e546459 100644 --- a/FtpServer.h +++ b/FtpServer.h @@ -502,6 +502,10 @@ #define FTP_CWD_SIZE FF_MAX_LFN+8 // max size of a directory name #define FTP_FIL_SIZE FF_MAX_LFN // max size of a file name #define FTP_CRED_SIZE 16 // max size of username and password +// One rendered listing entry: the two names at the limits above, the widest a long prints, +// the date makeDateTimeStrList() builds in its own char[25], the "Type=…;Size=…; " prefix, +// CRLF and the terminator. +#define FTP_LIST_LINE_SIZE (FTP_FIL_SIZE + FTP_CRED_SIZE + 64) #define FTP_NULLIP() IPAddress(0,0,0,0) enum ftpCmd { FTP_Stop = 0, // In this stage, stop any connection @@ -585,6 +589,18 @@ class FtpServer bool doStore(); bool doList(); bool doMlsd(); + // Sends what is still pending of listLine. True once the whole entry is away — only then + // is it counted in nbMatch, and any byte accepted pushes the idle deadline out, so a + // listing rides out a shut window exactly as a retrieve does. + bool sendListLine(); + // The one path from this server to the data socket. Returns what the socket took, and a + // non-zero take is what pushes the idle deadline out — so no transfer path can send bytes + // without the deadline noticing, or move the deadline without sending any. + size_t writeData(const uint8_t* p, size_t len); + void buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, const char* time); + void buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, time_t time); + void buildMlsdLine(bool isDirectory, const char* dtStr, long fz, const char* fn); + uint16_t finishListLine(int rendered); void closeTransfer(); // Ends a transfer as FAILED: closes the file and the directory, replies exactly once — // `reply` replaces the default "426 Transfer aborted" — and fires FTP_TRANSFER_ERROR for the @@ -834,6 +850,14 @@ class FtpServer uint16_t iCL; // pointer to cmdLine next incoming char uint16_t nbMatch; + // One listing entry, rendered whole before any of it is sent. The directory cursor has + // already moved past the entry by then, so a line the peer only half accepts has to be + // resumed from here — re-reading it is impossible, and a half line left in the stream + // corrupts every entry after it. + char listLine[ FTP_LIST_LINE_SIZE ]; + uint16_t listLineLen = 0; // rendered length; 0 = no entry pending + uint16_t listLineSent = 0; // how much of it the socket has taken + uint32_t millisDelay, // millisEndConnection, // millisBeginTrans, // store time of beginning of a transaction From db5a13dbda54dae765b7b377fbd2431017c3f35b Mon Sep 17 00:00:00 2001 From: srgg Date: Sat, 15 Aug 2026 00:38:37 -0600 Subject: [PATCH 4/5] fix: end a listing whose peer stopped reading The idle deadline covered RETR and the command stage only, so a LIST, NLST or MLSD that stopped being read had nothing to end it: the server retried its failing writes until the client gave up, and the session stayed open holding the queued send buffer. Observed on an ESP32-S3 with internal DRAM exhausted, 415 rounds before the run was stopped by hand. Listings refresh the deadline whenever the peer takes bytes, so covering them ends only the ones that stopped moving. STOR refreshes nothing yet and stays out. --- FtpServer.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/FtpServer.cpp b/FtpServer.cpp index f917201..2d487fc 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -448,13 +448,15 @@ uint8_t FtpServer::handleFTP() { // Out of the chain above, whose tail this was: a running transfer always took its own // branch, so the deadline was never reached — and doRetrieve() now waits a stalled peer - // out instead of aborting, leaving nothing else to end it. RETR refreshes this deadline - // as it sends; the other types never do, so bounding them would kill them mid-progress. - const bool in_retrieve = (transferStage == FTP_Retrieve); - if (cmdStage > FTP_Client && (transferStage == FTP_Close || in_retrieve) + // out instead of aborting, leaving nothing else to end it. RETR and the listings both + // refresh this deadline as they send, so it ends only what stopped sending; STOR does + // not refresh it and stays out of scope rather than die mid-progress. + const bool in_transfer = (transferStage == FTP_Retrieve || transferStage == FTP_List + || transferStage == FTP_Nlst || transferStage == FTP_Mlsd); + if (cmdStage > FTP_Client && (transferStage == FTP_Close || in_transfer) && !((int32_t) (millisEndConnection - millis()) > 0)) { DEBUG_PRINTLN(F("Timeout")); - if (in_retrieve) { + if (in_transfer) { // NOT closeTransfer(): that answers 226. abortTransfer() replies 426 and fires // FTP_TRANSFER_ERROR, which releases what the app took. abortTransfer(); From 0627572043937fbd9657d5e6ad1579f92ff73b88 Mon Sep 17 00:00:00 2001 From: srgg Date: Wed, 15 Jul 2026 23:58:44 -0600 Subject: [PATCH 5/5] feat: universal command hook (FtpResponse facade) + custom transfers Add a single extension point, setCommandHandler(FtpResponse&, command, parameter), invoked before the built-in dispatch for every command. The hook is void; its disposition is inferred from what it does with the FtpResponse capability facade: reply() -> command handled, built-in skipped rewriteCommand() -> library runs the rewritten command instead beginCustomTransfer -> caller-driven data transfer, built-in skipped (nothing) -> the original command runs FtpResponse is a narrow facade (friend of FtpServer) exposing only the hook-safe ops, so a hook cannot reach begin()/end()/handleFTP() or rebind itself. reply() being the handled-signal makes "reply then also pass" a non-issue by construction. rewriteCommand copies are bounded to the internal command[]/cmdLine[] buffers (strncpy/strnlen + alias-safe memmove). CustomTransfer streams cooperatively, one chunk per handleFTP() tick, so the control channel never blocks. onEnd() fires exactly once on every termination (done, ABOR, disconnect) through the abortTransfer() funnel, so resources never leak; it returns a custom final line or nullptr for the library default (226/426). (cherry picked from commit 35b7369e3d538c50e5327c48c8d34dc3e6ef9298) (cherry picked from commit 1dd4b3c90bfe9a24fddeefff20d28cc2e045f5ec) (cherry picked from commit 3796fc68a8c39a6e8e98604106ee5052f03c83eb) --- FtpServer.cpp | 118 ++++++++++++++++++++++++++++++++++++++++++++++++-- FtpServer.h | 91 +++++++++++++++++++++++++++++++++++++- 2 files changed, 205 insertions(+), 4 deletions(-) diff --git a/FtpServer.cpp b/FtpServer.cpp index 2d487fc..895a810 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -50,6 +50,7 @@ #include #include #include +#include // Implementations for 8.3 helpers (only for SD on AVR) #if (STORAGE_TYPE == STORAGE_SD) @@ -289,7 +290,9 @@ void FtpServer::begin( const char * _welcomeMessage ) { void FtpServer::end() { if(client.connected()) { - disconnectClient(); + disconnectClient(); // -> abortTransfer -> finishCustom if a custom transfer is live + } else if (transferStage == FTP_Custom) { // no client to disconnect, but a custom transfer is still + finishCustom( CustomTransfer::TR_ABORTED ); // in flight: free it so onEnd runs on this server-side stop too } #if FTP_SERVER_NETWORK_TYPE == NETWORK_ESP32 // && !defined(ARDUINO_ARCH_RP2040) @@ -441,7 +444,11 @@ uint8_t FtpServer::handleFTP() { } else if (transferStage == FTP_Mlsd) // MLSD listing { if (!doMlsd()) { - + transferStage = FTP_Close; + } + } else if (transferStage == FTP_Custom) // caller-driven cooperative transfer + { + if (!doCustom()) { transferStage = FTP_Close; } } @@ -523,6 +530,91 @@ void FtpServer::disconnectClient() } } +// --- FtpResponse: the narrow capability facade handed to a command hook (see FtpServer.h). +// Each method acts on the owning server's internals via friendship. --- +void FtpResponse::reply( const char * line ) +{ + server_.client.println( line ); + server_._replied = true; // marks the command handled (built-in skipped) +} + +void FtpResponse::rewriteCommand( const char * cmd, const char * param ) +{ + strncpy( server_.command, cmd, sizeof( server_.command ) - 1 ); + server_.command[ sizeof( server_.command ) - 1 ] = '\0'; + size_t n = param ? strnlen( param, sizeof( server_.cmdLine ) - 1 ) : 0; + memmove( server_.cmdLine, param ? param : "", n ); // memmove: param may point into cmdLine + server_.cmdLine[ n ] = '\0'; + server_.parameter = server_.cmdLine; +} + +bool FtpResponse::isAuthenticated() const +{ + return server_.cmdStage == FTP_Cmd; +} + +void FtpResponse::setAuthenticated( bool authenticated ) +{ + server_.cmdStage = authenticated ? FTP_Cmd : FTP_User; +} + +bool FtpResponse::beginCustomTransfer( const CustomTransfer * xfer, void * ctx ) +{ + if( ! server_.dataConnect( true ) ) // open data connection + send "150"; false on failure + return false; + server_._xfer = xfer; + server_._customCtx = ctx; + server_.transferStage = FTP_Custom; + server_.bytesTransfered = 0; // progress accumulator, same one the built-in uses + if( server_._transferCallback ) // mirror RETR: announce the transfer start (size unknown → 0) + server_._transferCallback( FTP_DOWNLOAD_START, xfer->name, 0 ); + // Mark THIS command handled. _replied is per-command (reset before each hook call), so a + // later command (e.g. ABOR) arriving while the transfer is still running is NOT swallowed — + // it falls through to its built-in. Keying off transferStage instead would block every + // command for the whole transfer. + server_._replied = true; + return true; +} + +// Push one chunk of the active custom transfer; false ends it (finishCustom already ran). +bool FtpServer::doCustom() +{ + int r = _xfer->sendChunk( _customCtx, data ); + if( r > 0 ) // bytes written this tick — report progress, continue + { + bytesTransfered += r; + if( FtpServer::_transferCallback ) + FtpServer::_transferCallback( FTP_DOWNLOAD, _xfer->name, bytesTransfered ); + return true; + } + if( r == 0 ) // yielded (nothing to send this tick); no progress + return true; + finishCustom( r == -1 ? CustomTransfer::TR_DONE : CustomTransfer::TR_ABORTED ); + return false; +} + +// End a custom transfer exactly once: onEnd() (caller cleanup + optional custom final line), +// then the final response (custom or default), close the data connection, clear state. +void FtpServer::finishCustom( CustomTransfer::TransferResult result ) +{ + const char * line = ( _xfer && _xfer->onEnd ) ? _xfer->onEnd( _customCtx, result ) : nullptr; + +#if defined(ESP8266) || defined(ESP32) + data.flush(); + delay( 20 ); // grace period to let TCP finish sending +#endif + data.stop(); + + client.println( line && *line ? line + : ( result == CustomTransfer::TR_DONE ? "226 Transfer complete" : "426 Transfer aborted" ) ); + if( FtpServer::_transferCallback ) // mirror RETR: report the terminal outcome + total bytes + FtpServer::_transferCallback( result == CustomTransfer::TR_DONE ? FTP_TRANSFER_STOP : FTP_TRANSFER_ERROR, + _xfer ? _xfer->name : nullptr, bytesTransfered ); + _xfer = nullptr; + _customCtx = nullptr; + transferStage = FTP_Close; +} + bool FtpServer::processCommand() { /////////////////////////////////////// @@ -535,6 +627,22 @@ bool FtpServer::processCommand() DEBUG_PRINT(F("Command is: ")); DEBUG_PRINTLN(command); + // Command hook: runs before the built-in dispatch. reply() marks the command as handled (the + // built-in is skipped); rewriteCommand() changes which command runs; doing nothing lets the + // original run. + _replied = false; + if( _commandHandler ) + { + FtpResponse res( *this ); + _commandHandler( res, command, parameter ); + } + + if( _replied ) + { + // _commandHandler processed this command + return true; + } + // // USER - User Identity // @@ -2186,7 +2294,11 @@ void FtpServer::closeTransfer() void FtpServer::abortTransfer(const __FlashStringHelper* reply) { - if( transferStage != FTP_Close ) + if( transferStage == FTP_Custom ) // caller-driven transfer: finishCustom owns onEnd + response + { + finishCustom( CustomTransfer::TR_ABORTED ); + } + else if( transferStage != FTP_Close ) { // A listing has no file and no byte count of its own: reporting one would hand the // application the name and total of whatever transfer ran before it. diff --git a/FtpServer.h b/FtpServer.h index e546459..5cfebc8 100644 --- a/FtpServer.h +++ b/FtpServer.h @@ -520,7 +520,8 @@ enum ftpTransfer { FTP_Close = 0, // In this stage, close data channel FTP_Store, // store file FTP_List, // list of files FTP_Nlst, // list of name of files - FTP_Mlsd }; // listing for machine processing + FTP_Mlsd, // listing for machine processing + FTP_Custom }; // caller-driven cooperative transfer (beginCustomTransfer) enum ftpDataConn { FTP_NoConn = 0,// No data connection FTP_Pasive, // Passive type @@ -549,6 +550,76 @@ enum FtpTransferOperation { FTP_UPLOAD_ERROR = 5 }; +class FtpServer; + +// A caller-driven data transfer, streamed cooperatively — one chunk per handleFTP() call — so +// the control channel stays responsive and nothing blocks. Use it for outputs with no standard +// FTP equivalent (a multi-file bundle, on-the-fly compression), started from a command hook via +// FtpResponse::beginCustomTransfer(). +struct CustomTransfer +{ + enum TransferResult { + TR_DONE, // transfer completed successfully + TR_ABORTED, // client aborted, connection lost, or a chunk reported an error + }; + + // Label reported to setTransferCallback on start/progress/end — a custom transfer has no + // `file`, so it supplies its own name (e.g. the download filename). May be nullptr. + const char * name; + + // Produce and send the next chunk into `data`, cooperatively (must NOT block). Return: + // >0 bytes written this tick — reported as progress; transfer continues + // 0 wrote nothing, not done — yield (e.g. socket full); retried next tick, no progress + // -1 done — TR_DONE + // <-1 error — TR_ABORTED + // The byte count drives the same progress callback the built-in RETR fires, so client + // progress and a host stall-watchdog keyed on setTransferCallback both work unchanged. + int (*sendChunk)( void * ctx, Client & data ); + + // Finalize — called EXACTLY once for every outcome (done, aborted, connection lost), so it + // owns cleanup (close files, free buffers). Return a custom final response line, or + // nullptr/"" for the library default (226 on success, 426 otherwise). The returned pointer + // must outlive the call (a literal, static, or a buffer owned by ctx). + const char * (*onEnd)( void * ctx, TransferResult result ); +}; + +// How a command hook reacts to the current command (Express-`res` style). A deliberately +// narrow handle: it exposes only the operations below, so a hook cannot begin()/end()/ +// handleFTP() or rebind itself. FtpServer builds one and passes it to the hook; the methods +// act on the server's internals through friendship. +class FtpResponse +{ +public: + // Send one FTP response line (e.g. "550 Read-only.") terminated by CRLF. Sending a reply + // from a command hook marks the command handled, so the built-in handler is skipped. + void reply( const char * line ); + + // Replace the command currently being processed; the library then dispatches the rewrite + // instead of the original (e.g., map "XLATEST" to "RETR " and reuse the built-in + // download). Copies are bound to the internal buffers, so cmd/param of any length are + // safe. Note: the rewrite is NOT re-filtered by the hook — apply your policy to the target + // verb, not just the incoming one. + void rewriteCommand( const char * cmd, const char * param ); + + // Whether the client has completed USER/PASS login. + bool isAuthenticated() const; + + // Force the login state, so a hook can implement its own authentication (a token, an IP + // allow-list, an extra factor) and have the rest of the session honor it. + void setAuthenticated( bool authenticated ); + + // Start a caller-driven data transfer from within a command hook: opens the data connection + // (sends "150"), then drives xfer->sendChunk once per handleFTP() call until it finishes and + // calls xfer->onEnd on any termination. `ctx` is passed to both callbacks. Returns false if + // the data connection could not be opened. Marks the command as handled. + bool beginCustomTransfer( const CustomTransfer * xfer, void * ctx ); + +private: + explicit FtpResponse( FtpServer & server ) : server_( server ) {} + FtpServer & server_; + friend class FtpServer; +}; + class FtpServer { public: @@ -573,10 +644,28 @@ class FtpServer _transferCallback = _transferCallbackParam; } + // Install a hook invoked for every command BEFORE the built-in dispatch. The hook is + // void; its intent is inferred from what it does with the FtpResponse: reply() takes the + // command over (the built-in is skipped), rewriteCommand() changes which command runs, + // and doing nothing lets the original run. The single extension point for rejecting, + // rewriting, implementing, or authorizing commands. + void setCommandHandler(void (*_commandHandlerParam)(FtpResponse& res, const char* command, const char* parameter) ) + { + _commandHandler = _commandHandlerParam; + } + private: // Use 32-bit sizes for callbacks to avoid truncation on platforms where "unsigned int" is 16-bit (AVR) void (*_callback)(FtpOperation ftpOperation, uint32_t freeSpace, uint32_t totalSpace){}; void (*_transferCallback)(FtpTransferOperation ftpOperation, const char* name, uint32_t transferredSize){}; + void (*_commandHandler)(FtpResponse& res, const char* command, const char* parameter){}; + bool _replied = false; // set by FtpResponse::reply() during a hook call; drives handled-vs-pass + friend class FtpResponse; + + const CustomTransfer * _xfer = nullptr; // active caller-driven transfer (FTP_Custom), or null + void * _customCtx = nullptr; + bool doCustom(); // push one chunk; false when the transfer ends + void finishCustom( CustomTransfer::TransferResult result ); // onEnd + final response + close, exactly once void iniVariables(); void clientConnected();