Commit b4bc1f55 authored by Reilly Grant's avatar Reilly Grant Committed by Commit Bot

Reland "Port policy::UploadJobImpl to network::SimpleURLLoader"

This reverts commit b624551f.

Reason for revert: Reverting this change broke the tree because of a patch that depends on it. Since the test is only flaky fixing the tree is better than causing more disturbance by reverting more changes.

Original change's description:
> Revert "Port policy::UploadJobImpl to network::SimpleURLLoader"
> 
> This reverts commit 581330a9.
> 
> Reason for revert: SimpleURLLoaderTest.OnUploadProgressCallback/3 is flaky
> 
> Original change's description:
> > Port policy::UploadJobImpl to network::SimpleURLLoader
> > 
> > This change ports UploadJobImpl from net::URLFetcher to SimpleURLLoader.
> > 
> > Since this request does not make use of the response a new method has
> > been added to SimpleURLLoader, DownloadToNull. This download mode still
> > reads the response body from the network but does not save it anywhere.
> > This is useful for requests which upload data and would otherwise have
> > to specify an arbitrary |max_body_size|. A maximum may still be set if
> > the caller wants to limit the amount of data transferred over the
> > network.
> > 
> > Bug: 773295
> > Cq-Include-Trybots: luci.chromium.try:linux_mojo
> > Change-Id: Iee1fdc6f7406066ced8c91122e22cd51ddcb1c5f
> > Reviewed-on: https://chromium-review.googlesource.com/1161551
> > Commit-Queue: Reilly Grant <reillyg@chromium.org>
> > Reviewed-by: Matt Menke <mmenke@chromium.org>
> > Reviewed-by: Julian Pastarmov <pastarmovj@chromium.org>
> > Cr-Commit-Position: refs/heads/master@{#581060}
> 
> TBR=pastarmovj@chromium.org,reillyg@chromium.org,mmenke@chromium.org
> 
> Change-Id: Ica09c85a12861d35efbdfd9a882d137bac6b9e56
> No-Presubmit: true
> No-Tree-Checks: true
> No-Try: true
> Bug: 773295, 872023
> Cq-Include-Trybots: luci.chromium.try:linux_mojo
> Reviewed-on: https://chromium-review.googlesource.com/1166288
> Reviewed-by: Reilly Grant <reillyg@chromium.org>
> Commit-Queue: Reilly Grant <reillyg@chromium.org>
> Cr-Commit-Position: refs/heads/master@{#581389}

TBR=pastarmovj@chromium.org,reillyg@chromium.org,mmenke@chromium.org

Change-Id: I793ab6c8c22c5bec97ef853f520dc506809970cb
No-Presubmit: true
No-Tree-Checks: true
No-Try: true
Bug: 773295, 872023
Cq-Include-Trybots: luci.chromium.try:linux_mojo
Reviewed-on: https://chromium-review.googlesource.com/1166301Reviewed-by: default avatarReilly Grant <reillyg@chromium.org>
Commit-Queue: Reilly Grant <reillyg@chromium.org>
Cr-Commit-Position: refs/heads/master@{#581394}
parent 89224288
......@@ -53,8 +53,6 @@ std::unique_ptr<UploadJob> ScreenshotDelegate::CreateUploadJob(
chromeos::DeviceOAuth2TokenService* device_oauth2_token_service =
chromeos::DeviceOAuth2TokenServiceFactory::Get();
scoped_refptr<net::URLRequestContextGetter> system_request_context =
g_browser_process->system_request_context();
std::string robot_account_id =
device_oauth2_token_service->GetRobotAccountId();
......@@ -80,7 +78,7 @@ std::unique_ptr<UploadJob> ScreenshotDelegate::CreateUploadJob(
)");
return std::unique_ptr<UploadJob>(new UploadJobImpl(
upload_url, robot_account_id, device_oauth2_token_service,
system_request_context, delegate,
g_browser_process->shared_url_loader_factory(), delegate,
base::WrapUnique(new UploadJobImpl::RandomMimeBoundaryGenerator),
traffic_annotation, base::ThreadTaskRunnerHandle::Get()));
}
......
......@@ -27,6 +27,7 @@
#include "components/policy/core/browser/browser_policy_connector.h"
#include "components/user_manager/user_manager.h"
#include "net/http/http_request_headers.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
namespace policy {
......@@ -144,8 +145,6 @@ std::unique_ptr<UploadJob> SystemLogDelegate::CreateUploadJob(
chromeos::DeviceOAuth2TokenService* device_oauth2_token_service =
chromeos::DeviceOAuth2TokenServiceFactory::Get();
scoped_refptr<net::URLRequestContextGetter> system_request_context =
g_browser_process->system_request_context();
std::string robot_account_id =
device_oauth2_token_service->GetRobotAccountId();
......@@ -173,7 +172,7 @@ std::unique_ptr<UploadJob> SystemLogDelegate::CreateUploadJob(
)");
return std::make_unique<UploadJobImpl>(
upload_url, robot_account_id, device_oauth2_token_service,
system_request_context, delegate,
g_browser_process->shared_url_loader_factory(), delegate,
std::make_unique<UploadJobImpl::RandomMimeBoundaryGenerator>(),
traffic_annotation, task_runner_);
}
......
......@@ -19,7 +19,8 @@
#include "net/base/mime_util.h"
#include "net/http/http_status_code.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "net/url_request/url_request_status.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "services/network/public/cpp/simple_url_loader.h"
namespace policy {
......@@ -27,7 +28,7 @@ namespace {
// Format for bearer tokens in HTTP requests to access OAuth 2.0 protected
// resources.
const char kAuthorizationHeaderFormat[] = "Authorization: Bearer %s";
const char kAuthorizationHeaderFormat[] = "Bearer %s";
// Value the "Content-Type" field will be set to in the POST request.
const char kUploadContentType[] = "multipart/form-data";
......@@ -151,7 +152,7 @@ UploadJobImpl::UploadJobImpl(
const GURL& upload_url,
const std::string& account_id,
OAuth2TokenService* token_service,
scoped_refptr<net::URLRequestContextGetter> url_context_getter,
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
Delegate* delegate,
std::unique_ptr<MimeBoundaryGenerator> boundary_generator,
net::NetworkTrafficAnnotationTag traffic_annotation,
......@@ -160,7 +161,7 @@ UploadJobImpl::UploadJobImpl(
upload_url_(upload_url),
account_id_(account_id),
token_service_(token_service),
url_context_getter_(url_context_getter),
url_loader_factory_(std::move(url_loader_factory)),
delegate_(delegate),
boundary_generator_(std::move(boundary_generator)),
traffic_annotation_(traffic_annotation),
......@@ -169,7 +170,7 @@ UploadJobImpl::UploadJobImpl(
task_runner_(task_runner),
weak_factory_(this) {
DCHECK(token_service_);
DCHECK(url_context_getter_);
DCHECK(url_loader_factory_);
DCHECK(delegate_);
SYSLOG(INFO) << "Upload job created.";
if (!upload_url_.is_valid()) {
......@@ -299,7 +300,7 @@ bool UploadJobImpl::SetUpMultipart() {
return true;
}
void UploadJobImpl::CreateAndStartURLFetcher(const std::string& access_token) {
void UploadJobImpl::CreateAndStartURLLoader(const std::string& access_token) {
// Ensure that the content has been prepared and the upload url is valid.
DCHECK_EQ(PREPARING_CONTENT, state_);
SYSLOG(INFO) << "Starting URL fetcher.";
......@@ -308,13 +309,20 @@ void UploadJobImpl::CreateAndStartURLFetcher(const std::string& access_token) {
content_type.append("; boundary=");
content_type.append(*mime_boundary_.get());
upload_fetcher_ = net::URLFetcher::Create(upload_url_, net::URLFetcher::POST,
this, traffic_annotation_);
upload_fetcher_->SetRequestContext(url_context_getter_.get());
upload_fetcher_->SetUploadData(content_type, *post_data_);
upload_fetcher_->AddExtraRequestHeader(
auto resource_request = std::make_unique<network::ResourceRequest>();
resource_request->method = "POST";
resource_request->url = upload_url_;
resource_request->headers.SetHeader(
net::HttpRequestHeaders::kAuthorization,
base::StringPrintf(kAuthorizationHeaderFormat, access_token.c_str()));
upload_fetcher_->Start();
url_loader_ = network::SimpleURLLoader::Create(std::move(resource_request),
traffic_annotation_);
url_loader_->AttachStringForUpload(*post_data_, content_type);
url_loader_->DownloadHeadersOnly(
url_loader_factory_.get(),
base::BindOnce(&UploadJobImpl::OnURLLoadComplete,
base::Unretained(this)));
}
void UploadJobImpl::StartUpload() {
......@@ -326,7 +334,7 @@ void UploadJobImpl::StartUpload() {
state_ = ERROR;
return;
}
CreateAndStartURLFetcher(access_token_);
CreateAndStartURLLoader(access_token_);
state_ = UPLOADING;
}
......@@ -356,7 +364,7 @@ void UploadJobImpl::OnGetTokenFailure(
void UploadJobImpl::HandleError(ErrorCode error_code) {
retry_++;
upload_fetcher_.reset();
url_loader_.reset();
SYSLOG(ERROR) << "Upload failed, error code: " << error_code;
......@@ -397,34 +405,31 @@ void UploadJobImpl::HandleError(ErrorCode error_code) {
}
}
void UploadJobImpl::OnURLFetchComplete(const net::URLFetcher* source) {
DCHECK_EQ(upload_fetcher_.get(), source);
void UploadJobImpl::OnURLLoadComplete(
scoped_refptr<net::HttpResponseHeaders> headers) {
DCHECK_EQ(UPLOADING, state_);
SYSLOG(INFO) << "URL fetch completed.";
const net::URLRequestStatus& status = source->GetStatus();
if (!status.is_success()) {
SYSLOG(ERROR) << "URLRequestStatus error " << status.error();
std::unique_ptr<network::SimpleURLLoader> url_loader = std::move(url_loader_);
if (!headers) {
SYSLOG(ERROR) << "SimpleURLLoader error " << url_loader->NetError();
HandleError(NETWORK_ERROR);
} else if (headers->response_code() == net::HTTP_OK) {
// Successful upload
access_token_.clear();
post_data_.reset();
state_ = SUCCESS;
UMA_HISTOGRAM_EXACT_LINEAR(kUploadJobSuccessHistogram, retry_,
static_cast<int>(UploadJobSuccess::REQUEST_MAX));
delegate_->OnSuccess();
} else if (headers->response_code() == net::HTTP_UNAUTHORIZED) {
SYSLOG(ERROR) << "Unauthorized request.";
HandleError(AUTHENTICATION_ERROR);
} else {
const int response_code = source->GetResponseCode();
if (response_code == net::HTTP_OK) {
// Successful upload
upload_fetcher_.reset();
access_token_.clear();
post_data_.reset();
state_ = SUCCESS;
UMA_HISTOGRAM_EXACT_LINEAR(
kUploadJobSuccessHistogram, retry_,
static_cast<int>(UploadJobSuccess::REQUEST_MAX));
delegate_->OnSuccess();
} else if (response_code == net::HTTP_UNAUTHORIZED) {
SYSLOG(ERROR) << "Unauthorized request.";
HandleError(AUTHENTICATION_ERROR);
} else {
SYSLOG(ERROR) << "POST request failed with HTTP status code "
<< response_code << ".";
HandleError(SERVER_ERROR);
}
SYSLOG(ERROR) << "POST request failed with HTTP status code "
<< headers->response_code() << ".";
HandleError(SERVER_ERROR);
}
}
......
......@@ -15,23 +15,28 @@
#include "base/threading/thread_checker.h"
#include "chrome/browser/chromeos/policy/upload_job.h"
#include "google_apis/gaia/oauth2_token_service.h"
#include "net/url_request/url_fetcher.h"
#include "net/url_request/url_fetcher_delegate.h"
#include "net/url_request/url_request_context_getter.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "url/gurl.h"
namespace base {
class SequencedTaskRunner;
}
namespace net {
class HttpResponseHeaders;
}
namespace network {
class SharedURLLoaderFactory;
class SimpleURLLoader;
} // namespace network
namespace policy {
// This implementation of UploadJob uses the OAuth2TokenService to acquire
// access tokens for the device management (cloud-based policy) server scope and
// uses a URLFetcher to upload data to the specified upload url.
class UploadJobImpl : public UploadJob,
public OAuth2TokenService::Consumer,
public net::URLFetcherDelegate {
// uses a SimpleURLLoader to upload data to the specified upload url.
class UploadJobImpl : public UploadJob, public OAuth2TokenService::Consumer {
public:
// UploadJobImpl uses a MimeBoundaryGenerator to generate strings which
// mark the boundaries between data segments.
......@@ -56,14 +61,15 @@ class UploadJobImpl : public UploadJob,
// |task_runner| must belong to the same thread from which the constructor and
// all the public methods are called.
UploadJobImpl(const GURL& upload_url,
const std::string& account_id,
OAuth2TokenService* token_service,
scoped_refptr<net::URLRequestContextGetter> url_context_getter,
Delegate* delegate,
std::unique_ptr<MimeBoundaryGenerator> boundary_generator,
net::NetworkTrafficAnnotationTag traffic_annotation,
scoped_refptr<base::SequencedTaskRunner> task_runner);
UploadJobImpl(
const GURL& upload_url,
const std::string& account_id,
OAuth2TokenService* token_service,
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory,
Delegate* delegate,
std::unique_ptr<MimeBoundaryGenerator> boundary_generator,
net::NetworkTrafficAnnotationTag traffic_annotation,
scoped_refptr<base::SequencedTaskRunner> task_runner);
~UploadJobImpl() override;
// UploadJob:
......@@ -103,15 +109,15 @@ class UploadJobImpl : public UploadJob,
void OnGetTokenFailure(const OAuth2TokenService::Request* request,
const GoogleServiceAuthError& error) override;
// net::URLFetcherDelegate:
void OnURLFetchComplete(const net::URLFetcher* source) override;
// Called when the SimpleURLLoader is finished.
void OnURLLoadComplete(scoped_refptr<net::HttpResponseHeaders> headers);
void HandleError(ErrorCode errorCode);
// Requests an access token for the upload scope.
void RequestAccessToken();
// Dispatches POST request to URLFetcher.
// Dispatches POST request.
void StartUpload();
// Constructs the body of the POST request by concatenating the
......@@ -122,9 +128,9 @@ class UploadJobImpl : public UploadJob,
// an error, clears |post_data_| and |mime_boundary_| and returns false.
bool SetUpMultipart();
// Assembles the request and starts the URLFetcher. Fails if another upload
// is still in progress or the content was not successfully encoded.
void CreateAndStartURLFetcher(const std::string& access_token);
// Assembles the request and starts the SimpleURLLoader. Fails if another
// upload is still in progress or the content was not successfully encoded.
void CreateAndStartURLLoader(const std::string& access_token);
// The URL to which the POST request should be directed.
const GURL upload_url_;
......@@ -135,8 +141,8 @@ class UploadJobImpl : public UploadJob,
// The token service used to retrieve the access token.
OAuth2TokenService* const token_service_;
// This is used to initialize the net::URLFetcher object.
const scoped_refptr<net::URLRequestContextGetter> url_context_getter_;
// This is used to initialize the network::SimpleURLLoader object.
const scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory_;
// The delegate to be notified of events.
Delegate* const delegate_;
......@@ -169,7 +175,7 @@ class UploadJobImpl : public UploadJob,
std::string access_token_;
// Helper to upload the data.
std::unique_ptr<net::URLFetcher> upload_fetcher_;
std::unique_ptr<network::SimpleURLLoader> url_loader_;
// The data chunks to be uploaded.
std::vector<std::unique_ptr<DataSegment>> data_segments_;
......
......@@ -27,8 +27,7 @@
#include "net/test/embedded_test_server/http_request.h"
#include "net/test/embedded_test_server/http_response.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "net/url_request/url_request_test_util.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "services/network/test/test_shared_url_loader_factory.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace policy {
......@@ -183,8 +182,8 @@ class UploadJobTestBase : public testing::Test, public UploadJob::Delegate {
// testing::Test:
void SetUp() override {
request_context_getter_ = new net::TestURLRequestContextGetter(
base::ThreadTaskRunnerHandle::Get());
url_loader_factory_ =
base::MakeRefCounted<network::TestSharedURLLoaderFactory>();
oauth2_service_.AddAccount("robot@gmail.com");
ASSERT_TRUE(test_server_.Start());
// Set retry delay to prevent timeouts
......@@ -201,9 +200,9 @@ class UploadJobTestBase : public testing::Test, public UploadJob::Delegate {
std::unique_ptr<UploadJobImpl::MimeBoundaryGenerator>
mime_boundary_generator) {
std::unique_ptr<UploadJob> upload_job(new UploadJobImpl(
GetServerURL(), kRobotAccountId, &oauth2_service_,
request_context_getter_.get(), this, std::move(mime_boundary_generator),
TRAFFIC_ANNOTATION_FOR_TESTS, base::ThreadTaskRunnerHandle::Get()));
GetServerURL(), kRobotAccountId, &oauth2_service_, url_loader_factory_,
this, std::move(mime_boundary_generator), TRAFFIC_ANNOTATION_FOR_TESTS,
base::ThreadTaskRunnerHandle::Get()));
std::map<std::string, std::string> header_entries;
header_entries.insert(std::make_pair(kCustomField1, "CUSTOM1"));
......@@ -220,7 +219,7 @@ class UploadJobTestBase : public testing::Test, public UploadJob::Delegate {
content::TestBrowserThreadBundle test_browser_thread_bundle_;
base::RunLoop run_loop_;
net::EmbeddedTestServer test_server_;
scoped_refptr<net::TestURLRequestContextGetter> request_context_getter_;
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory_;
MockOAuth2TokenService oauth2_service_;
std::unique_ptr<UploadJob::ErrorCode> expected_error_;
......
......@@ -188,6 +188,8 @@ class SimpleURLLoaderImpl : public SimpleURLLoader,
void DownloadToStringOfUnboundedSizeUntilCrashAndDie(
mojom::URLLoaderFactory* url_loader_factory,
BodyAsStringCallback body_as_string_callback) override;
void DownloadHeadersOnly(mojom::URLLoaderFactory* url_loader_factory,
HeadersOnlyCallback headers_only_callback) override;
void DownloadToFile(
mojom::URLLoaderFactory* url_loader_factory,
DownloadToFileCompleteCallback download_to_file_complete_callback,
......@@ -625,6 +627,58 @@ class SaveToStringBodyHandler : public BodyHandler,
DISALLOW_COPY_AND_ASSIGN(SaveToStringBodyHandler);
};
// BodyHandler that discards the response body.
class HeadersOnlyBodyHandler : public BodyHandler, public BodyReader::Delegate {
public:
HeadersOnlyBodyHandler(
SimpleURLLoaderImpl* simple_url_loader,
SimpleURLLoader::HeadersOnlyCallback headers_only_callback)
: BodyHandler(simple_url_loader),
headers_only_callback_(std::move(headers_only_callback)) {}
~HeadersOnlyBodyHandler() override {}
// BodyHandler implementation
void OnStartLoadingResponseBody(
mojo::ScopedDataPipeConsumerHandle body_data_pipe) override {
// TODO(crbug.com/871420): The request can be completed at this point
// however that requires more changes to SimpleURLLoader as OnComplete()
// will not have been called yet.
DCHECK(!body_reader_);
body_reader_ =
std::make_unique<BodyReader>(this, std::numeric_limits<int64_t>::max());
body_reader_->Start(std::move(body_data_pipe));
}
void NotifyConsumerOfCompletion(bool destroy_results) override {
body_reader_.reset();
std::move(headers_only_callback_)
.Run(simple_url_loader()->ResponseInfo()
? simple_url_loader()->ResponseInfo()->headers
: nullptr);
}
void PrepareToRetry(base::OnceClosure retry_callback) override {
body_reader_.reset();
std::move(retry_callback).Run();
}
private:
// BodyReader::Delegate implementation
net::Error OnDataRead(uint32_t length, const char* data) override {
return net::OK;
}
void OnDone(net::Error error, int64_t total_bytes) override {
simple_url_loader()->OnBodyHandlerDone(error, total_bytes);
}
SimpleURLLoader::HeadersOnlyCallback headers_only_callback_;
std::unique_ptr<BodyReader> body_reader_;
DISALLOW_COPY_AND_ASSIGN(HeadersOnlyBodyHandler);
};
// BodyHandler implementation for saving the response to a file
class SaveToFileBodyHandler : public BodyHandler {
public:
......@@ -1061,6 +1115,14 @@ void SimpleURLLoaderImpl::DownloadToStringOfUnboundedSizeUntilCrashAndDie(
Start(url_loader_factory);
}
void SimpleURLLoaderImpl::DownloadHeadersOnly(
mojom::URLLoaderFactory* url_loader_factory,
HeadersOnlyCallback headers_only_callback) {
body_handler_ = std::make_unique<HeadersOnlyBodyHandler>(
this, std::move(headers_only_callback));
Start(url_loader_factory);
}
void SimpleURLLoaderImpl::DownloadToFile(
mojom::URLLoaderFactory* url_loader_factory,
DownloadToFileCompleteCallback download_to_file_complete_callback,
......@@ -1246,6 +1308,7 @@ void SimpleURLLoaderImpl::FinishWithResult(int net_error) {
request_state_->finished = true;
request_state_->net_error = net_error;
// If it's a partial download or an error was received, erase the body.
bool destroy_results =
request_state_->net_error != net::OK && !allow_partial_results_;
......@@ -1311,7 +1374,6 @@ void SimpleURLLoaderImpl::Retry() {
url_loader_.reset();
request_state_ = std::make_unique<RequestState>();
body_handler_->PrepareToRetry(base::BindOnce(
&SimpleURLLoaderImpl::StartRequest, weak_ptr_factory_.GetWeakPtr(),
url_loader_factory_ptr_.get()));
......
......@@ -18,11 +18,15 @@
class GURL;
template <class T>
class scoped_refptr;
namespace base {
class FilePath;
}
namespace net {
class HttpResponseHeaders;
struct NetworkTrafficAnnotationTag;
struct RedirectInfo;
} // namespace net
......@@ -85,8 +89,14 @@ class COMPONENT_EXPORT(NETWORK_CPP) SimpleURLLoader {
using BodyAsStringCallback =
base::OnceCallback<void(std::unique_ptr<std::string> response_body)>;
// Callback used when download the response body to a file. On failure, |path|
// will be empty. It is safe to delete the SimpleURLLoader during the
// Callback used when ignoring the response body. |headers| are the received
// HTTP headers, or nullptr if none were received. It is safe to delete the
// SimpleURLLoader during the callback.
using HeadersOnlyCallback =
base::OnceCallback<void(scoped_refptr<net::HttpResponseHeaders> headers)>;
// Callback used when downloading the response body to a file. On failure,
// |path| will be empty. It is safe to delete the SimpleURLLoader during the
// callback.
using DownloadToFileCompleteCallback =
base::OnceCallback<void(base::FilePath path)>;
......@@ -120,7 +130,7 @@ class COMPONENT_EXPORT(NETWORK_CPP) SimpleURLLoader {
virtual ~SimpleURLLoader();
// Starts the request using |network_context|. The SimpleURLLoader will
// Starts the request using |url_loader_factory|. The SimpleURLLoader will
// accumulate all downloaded data in an in-memory string of bounded size. If
// |max_body_size| is exceeded, the request will fail with
// net::ERR_INSUFFICIENT_RESOURCES. |max_body_size| must be no greater than 1
......@@ -145,6 +155,14 @@ class COMPONENT_EXPORT(NETWORK_CPP) SimpleURLLoader {
mojom::URLLoaderFactory* url_loader_factory,
BodyAsStringCallback body_as_string_callback) = 0;
// Starts the request using |url_loader_factory|. The SimpleURLLoader will
// discard the response body as it is received and |headers_only_callback|
// will be invoked on completion. It is safe to delete the SimpleURLLoader in
// this callback.
virtual void DownloadHeadersOnly(
mojom::URLLoaderFactory* url_loader_factory,
HeadersOnlyCallback headers_only_callback) = 0;
// SimpleURLLoader will download the entire response to a file at the
// specified path. File I/O will happen on another sequence, so it's safe to
// use this on any sequence.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment