@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Prepare file responses for streaming chunks

Summary:
Ref T7149. This still buffers the whole file, but is reaaaaal close to not doing that.

Allow Responses to be streamed, and rewrite the range stuff in the FileResponse so it does not rely on having the entire content available.

Test Plan:
- Artificially slowed down downloads, suspended/resumed them (works in chrome, not so much in Safari/Firefox?)
- Played sounds in Safari/Chrome.
- Viewed a bunch of pages and files in every browser.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: joshuaspence, epriestley

Maniphest Tasks: T7149

Differential Revision: https://secure.phabricator.com/D12072

+127 -20
+56 -7
src/aphront/response/AphrontFileResponse.php
··· 3 3 final class AphrontFileResponse extends AphrontResponse { 4 4 5 5 private $content; 6 + private $contentIterator; 7 + private $contentLength; 8 + 6 9 private $mimeType; 7 10 private $download; 8 11 private $rangeMin; 9 12 private $rangeMax; 10 13 private $allowOrigins = array(); 14 + private $fileToken; 11 15 12 16 public function addAllowOrigin($origin) { 13 17 $this->allowOrigins[] = $origin; ··· 36 40 } 37 41 38 42 public function setContent($content) { 43 + $this->setContentLength(strlen($content)); 39 44 $this->content = $content; 45 + return $this; 46 + } 47 + 48 + public function setContentIterator($iterator) { 49 + $this->contentIterator = $iterator; 40 50 return $this; 41 51 } 42 52 43 53 public function buildResponseString() { 44 - if ($this->rangeMin || $this->rangeMax) { 45 - $length = ($this->rangeMax - $this->rangeMin) + 1; 46 - return substr($this->content, $this->rangeMin, $length); 47 - } else { 48 - return $this->content; 54 + return $this->content; 55 + } 56 + 57 + public function getContentIterator() { 58 + if ($this->contentIterator) { 59 + return $this->contentIterator; 49 60 } 61 + return parent::getContentIterator(); 62 + } 63 + 64 + public function setContentLength($length) { 65 + $this->contentLength = $length; 66 + return $this; 67 + } 68 + 69 + public function getContentLength() { 70 + return $this->contentLength; 50 71 } 51 72 52 73 public function setRange($min, $max) { ··· 55 76 return $this; 56 77 } 57 78 79 + public function setTemporaryFileToken(PhabricatorAuthTemporaryToken $token) { 80 + $this->fileToken = $token; 81 + return $this; 82 + } 83 + 84 + public function getTemporaryFileToken() { 85 + return $this->fileToken; 86 + } 87 + 58 88 public function getHeaders() { 59 89 $headers = array( 60 90 array('Content-Type', $this->getMimeType()), 61 - array('Content-Length', strlen($this->buildResponseString())), 91 + // This tells clients that we can support requests with a "Range" header, 92 + // which allows downloads to be resumed, in some browsers, some of the 93 + // time, if the stars align. 94 + array('Accept-Ranges', 'bytes'), 62 95 ); 63 96 64 97 if ($this->rangeMin || $this->rangeMax) { 65 - $len = strlen($this->content); 98 + $len = $this->getContentLength(); 66 99 $min = $this->rangeMin; 67 100 $max = $this->rangeMax; 68 101 $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}"); 102 + $content_len = ($max - $min) + 1; 103 + } else { 104 + $content_len = $this->getContentLength(); 69 105 } 106 + 107 + $headers[] = array('Content-Length', $this->getContentLength()); 70 108 71 109 if (strlen($this->getDownload())) { 72 110 $headers[] = array('X-Download-Options', 'noopen'); ··· 88 126 89 127 $headers = array_merge(parent::getHeaders(), $headers); 90 128 return $headers; 129 + } 130 + 131 + public function didCompleteWrite($aborted) { 132 + if (!$aborted) { 133 + $token = $this->getTemporaryFileToken(); 134 + if ($token) { 135 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 136 + $token->delete(); 137 + unset($unguarded); 138 + } 139 + } 91 140 } 92 141 93 142 }
+19 -1
src/aphront/response/AphrontResponse.php
··· 18 18 return $this->request; 19 19 } 20 20 21 + 22 + /* -( Content )------------------------------------------------------------ */ 23 + 24 + 25 + public function getContentIterator() { 26 + return array($this->buildResponseString()); 27 + } 28 + 29 + public function buildResponseString() { 30 + throw new PhutilMethodNotImplementedException(); 31 + } 32 + 33 + 34 + /* -( Metadata )----------------------------------------------------------- */ 35 + 36 + 21 37 public function getHeaders() { 22 38 $headers = array(); 23 39 if (!$this->frameable) { ··· 165 181 return gmdate('D, d M Y H:i:s', $epoch_timestamp).' GMT'; 166 182 } 167 183 168 - abstract public function buildResponseString(); 184 + public function didCompleteWrite($aborted) { 185 + return; 186 + } 169 187 170 188 }
+16 -3
src/aphront/sink/AphrontHTTPSink.php
··· 94 94 * @return void 95 95 */ 96 96 final public function writeResponse(AphrontResponse $response) { 97 - // Do this first, in case it throws. 98 - $response_string = $response->buildResponseString(); 97 + // Build the content iterator first, in case it throws. Ideally, we'd 98 + // prefer to handle exceptions before we emit the response status or any 99 + // HTTP headers. 100 + $data = $response->getContentIterator(); 99 101 100 102 $all_headers = array_merge( 101 103 $response->getHeaders(), ··· 105 107 $response->getHTTPResponseCode(), 106 108 $response->getHTTPResponseMessage()); 107 109 $this->writeHeaders($all_headers); 108 - $this->writeData($response_string); 110 + 111 + $abort = false; 112 + foreach ($data as $block) { 113 + if (!$this->isWritable()) { 114 + $abort = true; 115 + break; 116 + } 117 + $this->writeData($block); 118 + } 119 + 120 + $response->didCompleteWrite($abort); 109 121 } 110 122 111 123 ··· 115 127 abstract protected function emitHTTPStatus($code, $message = ''); 116 128 abstract protected function emitHeader($name, $value); 117 129 abstract protected function emitData($data); 130 + abstract protected function isWritable(); 118 131 119 132 }
+4
src/aphront/sink/AphrontIsolatedHTTPSink.php
··· 21 21 $this->data .= $data; 22 22 } 23 23 24 + protected function isWritable() { 25 + return true; 26 + } 27 + 24 28 public function getEmittedHTTPStatus() { 25 29 return $this->status; 26 30 }
+9
src/aphront/sink/AphrontPHPHTTPSink.php
··· 21 21 22 22 protected function emitData($data) { 23 23 echo $data; 24 + 25 + // Try to push the data to the browser. This has a lot of caveats around 26 + // browser buffering and display behavior, but approximately works most 27 + // of the time. 28 + flush(); 29 + } 30 + 31 + protected function isWritable() { 32 + return !connection_aborted(); 24 33 } 25 34 26 35 }
+17 -7
src/applications/files/controller/PhabricatorFileDataController.php
··· 117 117 } 118 118 } 119 119 120 - $data = $file->loadFileData(); 121 120 $response = new AphrontFileResponse(); 122 - $response->setContent($data); 123 121 if ($cache_response) { 124 122 $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); 125 123 } 124 + 125 + $begin = null; 126 + $end = null; 126 127 127 128 // NOTE: It's important to accept "Range" requests when playing audio. 128 129 // If we don't, Safari has difficulty figuring out how long sounds are ··· 133 134 if ($range) { 134 135 $matches = null; 135 136 if (preg_match('/^bytes=(\d+)-(\d+)$/', $range, $matches)) { 137 + // Note that the "Range" header specifies bytes differently than 138 + // we do internally: the range 0-1 has 2 bytes (byte 0 and byte 1). 139 + $begin = (int)$matches[1]; 140 + $end = (int)$matches[2] + 1; 141 + 136 142 $response->setHTTPResponseCode(206); 137 - $response->setRange((int)$matches[1], (int)$matches[2]); 143 + $response->setRange($begin, ($end - 1)); 138 144 } 139 145 } else if (isset($validated_token)) { 140 - // consume the one-time token if we have one. 141 - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 142 - $validated_token->delete(); 143 - unset($unguarded); 146 + // We set this on the response, and the response deletes it after the 147 + // transfer completes. This allows transfers to be resumed, in theory. 148 + $response->setTemporaryFileToken($validated_token); 144 149 } 145 150 146 151 $is_viewable = $file->isViewableInBrowser(); ··· 164 169 $response->setMimeType($file->getMimeType()); 165 170 $response->setDownload($file->getName()); 166 171 } 172 + 173 + $iterator = $file->getFileDataIterator($begin, $end); 174 + 175 + $response->setContentLength($file->getByteSize()); 176 + $response->setContentIterator($iterator); 167 177 168 178 return $response; 169 179 }
+6 -2
src/applications/files/query/PhabricatorFileSearchEngine.php
··· 25 25 } 26 26 27 27 public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { 28 - $query = id(new PhabricatorFileQuery()) 29 - ->withAuthorPHIDs($saved->getParameter('authorPHIDs', array())); 28 + $query = id(new PhabricatorFileQuery()); 29 + 30 + $author_phids = $saved->getParameter('authorPHIDs', array()); 31 + if ($author_phids) { 32 + $query->withAuthorPHIDs($author_phids); 33 + } 30 34 31 35 if ($saved->getParameter('explicit')) { 32 36 $query->showOnlyExplicitUploads(true);