Termix-SSH / Termix-SSH/Support
[BUG] Bulk upload of large files fails — unbounded parallel uploads, 5-minute server request timeout, leaked SFTP handles
Nobody has claimed this yet.
- Dominant language
- No language data
- Stars
- 28
- Forks
- 4
- PR merge metrics
- No merged PRs in 30d
Description
Title
Uploading many files at once fails on the larger ones; partial files left on the host
Platform
Desktop App - Windows
Server Installation Method
Proxmox (Community Scripts)
Version
2.7.1
CLI Installation Method
None
CLI Version
No response
Troubleshooting
- I have examined logs and tried to find the issue
- I have reviewed opened and closed issues
- I have tried restarting the application
- I have checked open issues and ensured this is not a duplicate
The Problem
Uploading ~45 files (~4 GB total) through the file manager consistently fails partway. About 37 landed; the rest — mostly the larger files — failed with a generic "failed to upload" toast. Reproduced on both the Windows desktop app and iOS (PWA). The upload path is shared between them, so this isn't platform-specific.
I read through the upload path and there are three distinct defects that compound. I don't think any one of them alone explains the behavior, but together they do.
1. Multi-file upload has no concurrency limit
src/ui/features/file-manager/FileManager.tsx:1124-1133
function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) { ... return; }
Array.from(fileList).forEach((file) => {
handleUploadFile(file); // never awaited
});
}
Selecting 45 files starts 45 uploads simultaneously. All three entry points funnel through here — the toolbar picker (FileManagerToolbar.tsx:332-336, multiple), drag-and-drop (FileManagerGrid.tsx:707-708), and Ctrl+U (FileManagerGrid.tsx:865-872).
Each one calls ensureSSHConnection() and then lands on a single shared SFTP channel — getSessionSftp() (src/backend/hosts/file-manager/session.ts:118-165) caches and returns one SFTPWrapper per session.
Worth flagging the inconsistency: the folder drop path already handles this correctly, serializing with for...of + await (FileManager.tsx:1082-1099). So dropping a folder of 45 files is sequential, but selecting the same 45 files is not.
The effect is that available bandwidth is split N ways, so every individual file's request stays open far longer than it needs to — which runs straight into the next defect.
2. The server kills any upload that takes more than 5 minutes
src/backend/hosts/file-manager/index.ts:3131
const server = app.listen(PORT, async () => { ... });
server.requestTimeout is never overridden. On Node 22 (the repo requires >=22.12.0) the default is:
Node v22.22.2
requestTimeout default: 300000 ms = 300 s
headersTimeout default: 60000 ms
requestTimeout is a total cap on receiving the entire request body — not an inactivity timer. Any upload whose body takes longer than 5 minutes to arrive has its connection destroyed by Node, regardless of how healthy the transfer is.
Combined with #1: with bandwidth split across concurrent uploads, each large file crawls, and the large files are exactly the ones that cross the 5-minute line. That matches the observed "the ones that failed were mainly the larger ones".
The client explicitly opts out of a client-side timeout (timeout: 0, ssh-file-operations-api.ts:508-512), so the client will wait indefinitely — but the server won't, and nothing surfaces that back to the user.
If a reverse proxy is in front (the Docker image bundles nginx), there's a second 5-minute wall at the same value: docker/nginx.conf:674-676 sets proxy_send_timeout 300s; proxy_read_timeout 300s. The body-size limit there is fine (client_max_body_size 5G), so this isn't a proxy misconfiguration — but both layers need raising.
3. Aborted uploads leak SFTP handles and leave truncated files behind
src/backend/hosts/file-manager/content-routes.ts:1356-1500
The route attaches error handlers to busboy, to the busboy file stream, and to the SFTP write stream — but never to req. I tested what actually happens when the connection dies mid-body, which is exactly what requestTimeout does:
events after 5s: req:aborted | req:error aborted | req:close
writeStream finished: false closed: false destroyed: false
bytes written to remote: 1048576 of 10485760 -> file left TRUNCATED
handle released? NO - leaked
req fires aborted/error/close, but busboy and the file stream emit nothing at all, so none of the existing handlers run. Per failed upload:
- the SFTP write stream is never destroyed → an open remote file handle is leaked for the life of the session
- a truncated partial file is left on the remote host, with no cleanup
- the request handler never resolves
I think this is what makes the failure progressive — ~37 succeed and then the rest fail consistently, rather than failing randomly. Each failure permanently burns a handle on the shared channel, and OpenSSH's sftp-server caps concurrent open handles (SFTP_MAX_HANDLES, 100 in stock OpenSSH). ssh2 already parses OpenSSH's limits@openssh.com extension including max-open-handles (ssh2/lib/protocol/SFTP.js:3055-3067), but Termix never queries or respects it.
For contrast, the chunk route does wire up req.on("error") (content-routes.ts:1623) — uploadFileStream just doesn't.
How to Reproduce
- Open the file manager on any SSH host.
- Select ~45 files totalling several GB with a mix of sizes, including a few in the hundreds-of-MB range.
- Upload them all in a single selection (toolbar picker, drag-and-drop, or Ctrl+U).
- Smaller files complete; the larger ones fail with a generic "failed to upload" toast, and truncated partial files are left behind on the remote host.
Additional Context
Three smaller things in the same path that make this harder to diagnose:
Truncated uploads can be reported as success. Both writeStream.on("finish") and on("close") reply 200 (content-routes.ts:1436-1478). Nothing compares bytes written against the source size. transfer-integrity.ts already implements verifySftpFileIntegrity() (SHA-256) and the server-to-server transfer engine uses it, but the browser upload path never calls it. A short write that ends cleanly is indistinguishable from a complete one.
No retry or resume anywhere in the browser upload path.
Failures collapse into one generic toast. On error the user gets t("fileManager.failedToUploadFile") (FileManager.tsx:1196); the HTTP status and server message only reach console.error. With 45 parallel uploads that's a burst of identical toasts with no indication of which files failed or why. The folder path at least reports an uploadedFolderPartial count — the multi-file path doesn't even do that. This is the main reason the failure looks arbitrary from the UI.
No test coverage — src/backend/tests/hosts/file-manager/ has no tests for uploadFileStream.
Suggested fixes
- Bound the concurrency in
handleFilesDropped— a small queue (2–4 at a time), or just reuse the sequentialfor...of+awaitpattern the folder path already uses. - Raise
server.requestTimeouton the file-manager server (0to disable, or a large value), and raise the matching nginxproxy_read_timeout/proxy_send_timeoutfor/ssh/file_manager/. Uploads are long-lived by nature; a 5-minute total cap isn't the right shape for them. - Handle
reqabort inuploadFileStream— onaborted/error/closebefore completion, destroy the SFTP write stream and unlink the partial remote file. That fixes both the handle leak and the truncated-file litter. sftp.stat()the destination and compare against the source size before reporting success.- Surface the server's actual error message and a per-file failure list.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with handleFilesDropped in src/ui/features/file-manager/FileManager.tsx and compare it with the sequential folder path, then trace uploadFileStream in content-routes.ts and the server setup in index.ts. Review docker/nginx.conf and the related SSH file-operation entry point before defining tests, since the payload notes no uploadFileStream coverage. Done means large multi-file uploads complete or fail cleanly without leaked handles or partial files.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, javascript, nginx, node.js
- Domain
- backend, full-stack, infrastructure
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100