Add a universal command hook (FtpResponse facade) + custom cooperative transfers - #97
Open
srgg wants to merge 3 commits into
Open
Add a universal command hook (FtpResponse facade) + custom cooperative transfers#97srgg wants to merge 3 commits into
srgg wants to merge 3 commits into
Conversation
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).
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.
…ransfer 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.
srgg
force-pushed
the
feat/command-hook
branch
from
August 11, 2026 22:30
fda28a1 to
d7c5cf0
Compare
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.
Command hook extension
Problem
Applications currently have no way to influence how the server handles individual FTP commands.
The existing
setCallbackandsetTransferCallbackAPIs are invoked only after a command has already been processed and returnvoid. They are useful for observing server activity, but they cannot modify, reject, or replace command handling.As a result, several common extension scenarios are currently impossible or require patching the server implementation:
Proposal
This PR introduces an optional command hook that gives applications control over the handling of individual FTP commands.
The hook is fully backward-compatible. If no hook is registered, the server behaves exactly as it does today.
API
The hook runs for every command, immediately before the server's built-in handler. It does not return a value. Instead, its interaction with
FtpResponsedetermines what happens next:res.reply("550 …")res.rewriteCommand(cmd, param)res.beginCustomTransfer(&xfer, ctx)Because handling is expressed through
FtpResponse, there is no return value to interpret incorrectly. Callingreply()marks the command as handled, so the hook cannot both send a reply and allow the built-in handler to send another one.FtpResponseintentionally exposes only these operations. Internal server methods such asbegin(),end(),handleFTP(), and callback registration are not reachable from the hook.Custom transfers
beginCustomTransfer()allows a hook to stream data that no standard FTP command can produce, such as generated reports, multi-file bundles, or on-the-fly compression.Transfers are driven incrementally from
handleFTP(), one chunk per call, so the control connection remains responsive and never blocks waiting for the producer.onEnd()is guaranteed to run exactly once regardless of how the transfer finishes:ABORend()This provides a single place to close files, release buffers, and perform cleanup.
Transfer byte counts continue flowing through the existing
setTransferCallback, so progress reporting and stall watchdogs continue to work for custom transfers.Safety
rewriteCommandcopies into the server's fixedcommand[]andcmdLine[]buffers using bounded operations (strncpy/strnlen). It also handles cases whereparampoints into the same buffer usingmemmove.Examples
Read-only server
Reject write operations while allowing all normal read commands to continue through the built-in handler:
Command aliasing
Rewrite a command while continuing to use the built-in handler.
For example,
RETR LATESTcan be translated into a request for the newest file while preserving existing RETR behavior such as resume support and access checks:Custom authentication
The hook runs before the built-in authentication flow, allowing applications to provide their own authentication mechanism.
For example, the library can still handle
USER, while the hook handlesPASSusing a token, hash, rotating one-time code, or external identity service:setAuthenticated(true)marks the session as authenticated, allowing subsequent commands to pass the built-in authentication check.isAuthenticated()can be used by hooks that need to inspect the current session state.Implement a command
Handle a custom or extension command directly without involving a data connection:
Custom generated transfer
Serve generated data that does not exist as a file on disk:
Compatibility
This change is purely additive.
No existing function signatures change, and when no command hook is installed the server behavior is unchanged.