0

Why a macOS Folder Upload Can Sit at 99% After Every Byte Has Been Sent

Uploading one file over SSH has a convenient progress model: divide the bytes written by the file size. Uploading a folder from macOS to Linux is less honest. The visible files are only part of the job, the stream has archive overhead, and the receiving server may still be extracting data after the sender reaches end-of-file.

I ran into this while implementing folder transfers in a native macOS SSH client. The most useful lesson was that a folder upload needs two progress models: one for the byte stream and another for remote completion.

The folder size is not the stream size

A common first implementation walks the directory, adds every regular file size, and uses that sum as the denominator. The transfer then pipes a tar archive through SSH:

local tar -> SSH stream -> remote tar extraction

That denominator cannot match the bytes crossing the pipe. A tar stream also contains:

  • a 512-byte header for every file and directory;
  • file contents padded to a 512-byte boundary;
  • extra records for long path names;
  • two terminating zero blocks;
  • final blocking-factor padding, commonly to 10,240 bytes.

With many small files, metadata and padding can be a meaningful share of the archive. A progress bar based only on file contents may reach 100% early or even report more bytes sent than the declared total.

The estimator therefore has to model the archive produced by the exact tar command. In simplified Swift, the core arithmetic looks like this:

func paddedTo512(_ value: Int64) -> Int64 {
    (value + 511) / 512 * 512
}

func archiveEntryBytes(pathLength: Int, fileSize: Int64?) -> Int64 {
    var bytes: Int64 = 512

    if pathLength > 99 {
        bytes += 512 + paddedTo512(Int64(pathLength + 1))
    }

    if let fileSize {
        bytes += paddedTo512(fileSize)
    }

    return bytes
}

func archiveTotal(_ entries: [Entry]) -> Int64 {
    let body = entries.reduce(Int64(0)) { total, entry in
        total + archiveEntryBytes(
            pathLength: entry.archivePath.utf8.count,
            fileSize: entry.isRegularFile ? entry.size : nil
        )
    }

    return (body + 1024 + 10239) / 10240 * 10240
}

This can deliberately overestimate unusual links slightly. That is safer than showing progress above 100%, provided the UI treats the estimate as an estimate rather than proof of completion.

Do not send macOS metadata to a Linux server by accident

macOS files can carry extended attributes and resource-fork metadata that Linux users did not ask for. Depending on the archive tool and flags, a folder upload can produce AppleDouble files, restore irrelevant ownership data, or flood stderr with per-file warnings.

For a macOS-to-Linux transfer, the local producer can explicitly exclude that metadata:

/usr/bin/tar \
  --no-mac-metadata \
  --no-xattrs \
  -cf - \
  -C /local/parent folder-name

The flags are not merely cleanup. They are part of the progress contract. If the estimator counts one archive shape but the running command emits another, the denominator is wrong again.

The same principle applies to ignore rules. If .DS_Store or other files are excluded, both enumeration and archive creation must use the same selection. Progress accounting should describe the stream that actually exists.

End-of-file is not end-of-operation

The subtle bug appears after the local tar process closes its output. At that moment every archive byte may have entered the SSH pipe, but the remote process can still be:

  • reading buffered network data;
  • writing file contents to storage;
  • creating directories and links;
  • restoring permissions;
  • flushing filesystem buffers;
  • reporting an extraction error.

Declaring success when the producer reaches EOF creates false positives. Declaring success when the SSH process exits, without checking the remote extractor's status, can hide failures such as a full disk or denied permission.

The receiver is the completion authority. The operation is complete only when the remote extraction process exits successfully.

That leads to a small state machine:

preparing
  -> streaming bytes (0% ... 98%)
  -> sender EOF / remote processing (99%)
  -> receiver exit 0 (100%, completed)
  -> receiver non-zero (failed)

The 99% state is not a cosmetic delay. It exposes a real phase boundary. A short label such as Processing on server... is much more truthful than a frozen byte counter.

Relay the stream if you need trustworthy progress

Connecting the producer's stdout directly to the SSH process is efficient, but the application then has no reliable count of bytes that passed through the pipe. A practical design is to relay chunks through the parent process:

tar stdout -> app relay -> ssh stdin

For each chunk, increment an atomic byte counter and write the data onward. Publish UI updates on a timer, for example every 250 to 300 milliseconds, instead of invalidating the interface for every network read.

The relay also gives cancellation one place to act: stop reading, close the consumer's stdin, and terminate both processes. Cancellation should be a separate terminal state, not an error rewritten as success because the sender happened to finish first.

Progress is a protocol, not a percentage

The robust design is less about drawing a progress bar and more about defining who can assert each transition:

  • directory enumeration defines the expected archive shape;
  • the relay reports observed stream bytes;
  • producer EOF begins the remote-processing phase;
  • receiver exit status determines success or failure;
  • cancellation overrides every in-flight phase.

Once those responsibilities are explicit, the UI becomes simple. Cap estimated byte progress below completion, explain the server-side phase, and emit 100% only after the receiving process confirms success.

The broader lesson applies beyond SSH. Any workflow that sends a compressed archive and performs work on the destination has at least two clocks: bytes delivered and work committed. Treating them as one is how a progress bar becomes confidently wrong.


I encountered this problem while building Nexus Shell, a native macOS SSH client. The code samples above are simplified to focus on the transfer model; no product link or promotion is required to apply the approach.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí