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;
Only file is only partially downloaded. A possible solution: