Skip to content

Large file download fails #26

Description

@jukkakangas

Only file is only partially downloaded. A possible solution:

diff --git a/src/Q/WebServer.php b/src/Q/WebServer.php
index 7760a3c..cd53ec9 100644
--- a/src/Q/WebServer.php
+++ b/src/Q/WebServer.php
@@ -17,6 +17,55 @@
 class Q_WebServer
 {
 
+	/**
+	 * Write a string to a stream socket in full, looping over partial writes.
+	 *
+	 * PHP's fwrite() on a stream socket (plain TCP or TLS) is NOT guaranteed
+	 * to write the entire buffer in one call, even in blocking mode: once the
+	 * kernel/OpenSSL send buffer fills up, fwrite() returns the number of
+	 * bytes it actually managed to write (which can be far less than
+	 * requested for large buffers, e.g. multi-megabyte response bodies) and
+	 * the remainder is silently dropped by every call site in this file that
+	 * did a single unchecked `@fwrite($client, $largeString)` — truncating
+	 * any response body larger than roughly one send-buffer's worth (in
+	 * practice, a few hundred KB to a few MB depending on the OS/TLS stack).
+	 * This was reproduced with plain HTTP (no TLS involved) on a ~47MB file:
+	 * only the first ~3MB reached the client even though the server logged a
+	 * clean 200 in a few seconds. Every response-writing call site must loop
+	 * until all bytes are written (or the stream errors out) instead of
+	 * trusting a single fwrite() to finish the job.
+	 *
+	 * @method writeAll
+	 * @static
+	 * @param {resource} $stream
+	 * @param {string} $data
+	 * @return {boolean} true if all bytes were written, false on a write error
+	 */
+	static function writeAll($stream, $data)
+	{
+		$length = strlen($data);
+		$written = 0;
+		while ($written < $length) {
+			$chunk = @fwrite($stream, substr($data, $written));
+			if ($chunk === false) {
+				return false;
+			}
+			if ($chunk === 0) {
+				// Stream buffer is temporarily full (non-blocking mode) or the
+				// peer stopped reading; avoid a tight busy-loop and give the
+				// OS a moment before retrying, but bail out if the stream has
+				// gone away in the meantime.
+				if (feof($stream)) {
+					return false;
+				}
+				usleep(1000);
+				continue;
+			}
+			$written += $chunk;
+		}
+		return true;
+	}
+
 	/**
 	 * Search paths for app/user files.
 	 *
@@ -631,7 +680,7 @@ class Q_WebServer
 
 		// Rate limit check
 		if (!self::checkRateLimit($ip)) {
-			@fwrite($client, "HTTP/1.1 429 Too Many Requests\r\n"
+			self::writeAll($client, "HTTP/1.1 429 Too Many Requests\r\n"
 				. "Retry-After: 60\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
 			@fclose($client);
 			unset(self::$clients[$key], self::$buffers[$key], self::$keepAliveCount[$key],
@@ -1853,7 +1902,7 @@ class Q_WebServer
 					// If streaming already sent headers, just terminate and close
 					if (Q_WebServer_State::isStreaming()
 						&& !empty($parsed['_streamingSent'])) {
-						@fwrite($client, "0\r\n\r\n"); // chunked terminator
+						self::writeAll($client, "0\r\n\r\n"); // chunked terminator
 						@fclose($client);
 						return;
 					}
@@ -2332,7 +2381,7 @@ WORKER;
 		foreach ($extraHeaders as $pair) {
 			$out .= $pair[0] . ': ' . $pair[1] . "\r\n";
 		}
-		@fwrite($client, $out . "\r\n" . $body);
+		self::writeAll($client, $out . "\r\n" . $body);
 
 		self::$lastStatus = $status;
 		return false;
@@ -2396,9 +2445,9 @@ WORKER;
 			self::$lastStatus = 200;
 			self::$lastBytes = $cached['bodyLen'];
 			if ($method === 'HEAD') {
-				@fwrite($client, $cached['head'][$connKey]);
+				self::writeAll($client, $cached['head'][$connKey]);
 			} else {
-				@fwrite($client, $cached['full'][$connKey]);
+				self::writeAll($client, $cached['full'][$connKey]);
 			}
 			return;
 		}
@@ -2449,7 +2498,7 @@ WORKER;
 				. "Connection: $connHeader\r\n\r\n";
 			self::$lastStatus = 200;
 			self::$lastBytes = $preComp['size'];
-			@fwrite($client, $method === 'HEAD' ? $out : $out . file_get_contents($preComp['path']));
+			self::writeAll($client, $method === 'HEAD' ? $out : $out . file_get_contents($preComp['path']));
 			return;
 		}
 
@@ -2479,7 +2528,7 @@ WORKER;
 					. "Connection: $connHeader\r\n\r\n";
 				self::$lastStatus = 200;
 				self::$lastBytes = strlen($body);
-				@fwrite($client, $method === 'HEAD' ? $out : $out . $body);
+				self::writeAll($client, $method === 'HEAD' ? $out : $out . $body);
 				return;
 			}
 		}
@@ -2494,7 +2543,7 @@ WORKER;
 		self::$lastStatus = 200;
 		self::$lastBytes = $size;
 		$headStr = $keepAlive ? $kaHead : $clHead;
-		@fwrite($client, $method === 'HEAD' ? $headStr : $headStr . $body);
+		self::writeAll($client, $method === 'HEAD' ? $headStr : $headStr . $body);
 
 		// Cache if small enough
 		if ($size <= self::$fileCacheMaxFile
@@ -3708,19 +3757,19 @@ HTML;
 			. "\r\nContent-Type: $type\r\nContent-Length: " . strlen($body)
 			. "\r\nConnection: $conn\r\n";
 		foreach ($extra as $k => $v) $out .= "$k: $v\r\n";
-		@fwrite($client, $out . "\r\n" . $body);
+		self::writeAll($client, $out . "\r\n" . $body);
 	}
 
 	static function sendRedirect($client, $loc, $permanent = false) {
 		$code = $permanent ? 301 : 302;
 		$text = $permanent ? 'Moved Permanently' : 'Found';
-		@fwrite($client, "HTTP/1.1 $code $text\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+		self::writeAll($client, "HTTP/1.1 $code $text\r\nLocation: $loc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
 		self::$lastStatus = $code;
 	}
 
 	private static function sendNotModified($client, $etag, $mtime, $keepAlive = false) {
 		$conn = $keepAlive ? 'keep-alive' : 'close';
-		@fwrite($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n"
+		self::writeAll($client, "HTTP/1.1 304 Not Modified\r\nETag: $etag\r\n"
 			. "Last-Modified: " . gmdate('D, d M Y H:i:s', $mtime) . " GMT\r\n"
 			. "Cache-Control: public, max-age=0, must-revalidate\r\nContent-Length: 0\r\nConnection: $conn\r\n\r\n");
 		self::$lastStatus = 304;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions