Termix-SSH / Termix-SSH/Support

[BUG] Bulk upload of large files fails — unbounded parallel uploads, 5-minute server request timeout, leaked SFTP handles

Open
#1,231 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug file-manager performance platform-desktop platform-ios platform-proxmox platform-web platform-windows
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 channelgetSessionSftp() (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
  1. Open the file manager on any SSH host.
  2. Select ~45 files totalling several GB with a mix of sizes, including a few in the hundreds-of-MB range.
  3. Upload them all in a single selection (toolbar picker, drag-and-drop, or Ctrl+U).
  4. 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 coveragesrc/backend/tests/hosts/file-manager/ has no tests for uploadFileStream.

Suggested fixes
  1. Bound the concurrency in handleFilesDropped — a small queue (2–4 at a time), or just reuse the sequential for...of + await pattern the folder path already uses.
  2. Raise server.requestTimeout on the file-manager server (0 to disable, or a large value), and raise the matching nginx proxy_read_timeout/proxy_send_timeout for /ssh/file_manager/. Uploads are long-lived by nature; a 5-minute total cap isn't the right shape for them.
  3. Handle req abort in uploadFileStream — on aborted/error/close before completion, destroy the SFTP write stream and unlink the partial remote file. That fixes both the handle leak and the truncated-file litter.
  4. sftp.stat() the destination and compare against the source size before reporting success.
  5. Surface the server's actual error message and a per-file failure list.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.