Skip to content

Commit c5bb3c9

Browse files
authored
16612 download large files (#16627)
* #16612 support downloading large files from Grid I added a new Grid endpoint "/se/files/:name" which allows downloading the file directly, without encoding it to Base64 and adding to Json. This transformation kills the performance and causes OutOfMemory errors for large files (e.g. 256+ MB). NB! Be sure that `toString()` method of objects (HttpRequest, HttpResponse, Contents.Supplier) never returns too long string - it spam debug logs and can cause OOM during debugging. * #16612 extract anonymous implementations of `Contents.Supplier` to separate classes It makes debugging easier. You can easily see what instances they are and where they come from. * #16612 optimize method `RemoteWebDriver.downloadFile()` Instead of reading the whole file to a byte array, just save given InputStream directly to the file. Now it can download large files (I tried 4GB) while consuming very low memory. * #16612 just in case, return `Contents.fromStream` only when downloading files. For json responses, still return `Contents.bytes` which allows re-reading its content multiple times. Just in case. * #16612 fix flaky test: wait until the downloads folder gets deleted After stopping a Grid node, the folder is deleted asynchronously (by cache removal listener). So we need to wait for it in test. * #16612 fix flaky test: wait until the grid node is fully stopped At least on my machine, stopping the node takes some time, and any checks right after `node.stop(sessionId)` often can fail. * #16612 fix flaky test LocalNewSessionQueueTest Gr... This is extremely hard to debug test. After hours of debugging, I came to a conclusion that we just need to increase the timeout. On my machine, `latch` gets decreased after ~1.2 seconds. So 1 second was not enough. * #16612 fix flaky test JdkHttpClientTest I don't know why, but sometimes we receive `HttpTimeoutException` instead of `InterruptedException`. Seems reasonable to consider execution as interrupted in both cases. (?) * #16612 remove unneeded code None of `is.readNBytes` implementations returns -1. It always returns 0 or positive number. * #16612 upload logs in case of test failure * ignore few more IDEA files * #16612 slightly improve logging in `W3CHttpResponseCodec.decode` Don't log the entire response body - just content type and length.
1 parent 24bc928 commit c5bb3c9

31 files changed

+642
-153
lines changed

.github/workflows/bazel.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ jobs:
185185
if: ${{ always() && inputs.artifact-name != 'ignore-artifacts' }}
186186
run: |
187187
git diff > changes.patch
188+
- name: "Upload test logs"
189+
if: failure()
190+
uses: actions/upload-artifact@v5
191+
with:
192+
name: test-logs-${{ inputs.os }}-${{ inputs.name }}-${{ inputs.browser }}
193+
retention-days: 7
194+
path: |
195+
**/*.log
188196
- name: "Upload changes"
189197
if: ${{ always() && inputs.artifact-name != 'ignore-artifacts' }}
190198
uses: actions/upload-artifact@v4

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ javascript/selenium-webdriver/lib/atoms/is-displayed.js
2626
javascript/selenium-webdriver/lib/atoms/mutation-listener.js
2727
javascript/safari-driver/node_modules/
2828
javascript/webdriver/devtools/types/
29+
.idea/bazelVersionCache.xml
30+
.idea/GitLink.xml
2931
.idea/vcs.xml
3032
.idea/workspace.xml
3133
.idea/dictionaries/
34+
.idea/runConfigurations.xml
3235
.idea/scopes/
36+
.idea/shelf
3337
.idea/sonarlint/
3438
.idea/qaplug_profiles.xml
3539
out

java/src/org/openqa/selenium/grid/data/NodeStatus.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,4 +248,10 @@ public Map<String, Object> toJson() {
248248

249249
return unmodifiableMap(toReturn);
250250
}
251+
252+
@Override
253+
public String toString() {
254+
return String.format(
255+
"NodeStatus(availability: %s, slots:%s, uri:%s)", availability, slots.size(), externalUri);
256+
}
251257
}

java/src/org/openqa/selenium/grid/data/Session.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,9 @@ public boolean equals(Object that) {
144144
public int hashCode() {
145145
return Objects.hash(id, uri);
146146
}
147+
148+
@Override
149+
public String toString() {
150+
return String.format("Session(id:%s, url:%s, started at: %s)", id, uri, getStartTime());
151+
}
147152
}

java/src/org/openqa/selenium/grid/node/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ java_library(
2222
"//java/src/org/openqa/selenium/remote",
2323
"//java/src/org/openqa/selenium/status",
2424
artifact("com.google.guava:guava"),
25+
artifact("org.jspecify:jspecify"),
2526
],
2627
)

java/src/org/openqa/selenium/grid/node/Node.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ protected Node(
171171
get("/session/{sessionId}/se/files")
172172
.to(params -> new DownloadFile(this, sessionIdFrom(params)))
173173
.with(spanDecorator("node.download_file")),
174+
get("/session/{sessionId}/se/files/{fileName}")
175+
.to(params -> new DownloadFile(this, sessionIdFrom(params)))
176+
.with(spanDecorator("node.download_file")),
174177
post("/session/{sessionId}/se/files")
175178
.to(params -> new DownloadFile(this, sessionIdFrom(params)))
176179
.with(spanDecorator("node.download_file")),

java/src/org/openqa/selenium/grid/node/local/LocalNode.java

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@
1919

2020
import static com.google.common.collect.ImmutableSet.toImmutableSet;
2121
import static java.nio.file.Files.readAttributes;
22+
import static java.time.ZoneOffset.UTC;
23+
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
24+
import static java.util.Arrays.asList;
25+
import static java.util.Locale.US;
26+
import static java.util.Objects.requireNonNullElseGet;
2227
import static org.openqa.selenium.HasDownloads.DownloadedFile;
2328
import static org.openqa.selenium.concurrent.ExecutorServices.shutdownGracefully;
2429
import static org.openqa.selenium.grid.data.Availability.DOWN;
2530
import static org.openqa.selenium.grid.data.Availability.DRAINING;
2631
import static org.openqa.selenium.grid.data.Availability.UP;
2732
import static org.openqa.selenium.grid.node.CapabilityResponseEncoder.getEncoder;
33+
import static org.openqa.selenium.net.Urls.urlDecode;
2834
import static org.openqa.selenium.remote.CapabilityType.ENABLE_DOWNLOADS;
2935
import static org.openqa.selenium.remote.HttpSessionId.getSessionId;
3036
import static org.openqa.selenium.remote.RemoteTags.CAPABILITIES;
@@ -39,6 +45,7 @@
3945
import com.github.benmanes.caffeine.cache.Ticker;
4046
import com.google.common.annotations.VisibleForTesting;
4147
import com.google.common.collect.ImmutableList;
48+
import com.google.common.net.MediaType;
4249
import java.io.Closeable;
4350
import java.io.File;
4451
import java.io.IOException;
@@ -47,9 +54,11 @@
4754
import java.net.URI;
4855
import java.net.URISyntaxException;
4956
import java.nio.file.attribute.BasicFileAttributes;
57+
import java.nio.file.attribute.FileTime;
5058
import java.time.Clock;
5159
import java.time.Duration;
5260
import java.time.Instant;
61+
import java.time.format.DateTimeFormatter;
5362
import java.util.Arrays;
5463
import java.util.HashMap;
5564
import java.util.List;
@@ -107,6 +116,7 @@
107116
import org.openqa.selenium.json.Json;
108117
import org.openqa.selenium.remote.Browser;
109118
import org.openqa.selenium.remote.SessionId;
119+
import org.openqa.selenium.remote.http.Contents;
110120
import org.openqa.selenium.remote.http.HttpMethod;
111121
import org.openqa.selenium.remote.http.HttpRequest;
112122
import org.openqa.selenium.remote.http.HttpResponse;
@@ -123,6 +133,7 @@ public class LocalNode extends Node implements Closeable {
123133

124134
private static final Json JSON = new Json();
125135
private static final Logger LOG = Logger.getLogger(LocalNode.class.getName());
136+
private static final DateTimeFormatter HTTP_DATE_FORMAT = RFC_1123_DATE_TIME.withLocale(US);
126137

127138
private final EventBus bus;
128139
private final URI externalUri;
@@ -742,9 +753,12 @@ public HttpResponse downloadFile(HttpRequest req, SessionId id) {
742753
Optional.ofNullable(tempFS.getBaseDir().listFiles()).orElse(new File[] {})[0];
743754

744755
try {
745-
if (req.getMethod().equals(HttpMethod.GET)) {
756+
if (req.getMethod().equals(HttpMethod.GET) && req.getUri().endsWith("/se/files")) {
746757
return listDownloadedFiles(downloadsDirectory);
747758
}
759+
if (req.getMethod().equals(HttpMethod.GET)) {
760+
return getDownloadedFile(downloadsDirectory, extractFileName(req));
761+
}
748762
if (req.getMethod().equals(HttpMethod.DELETE)) {
749763
return deleteDownloadedFile(downloadsDirectory);
750764
}
@@ -754,6 +768,19 @@ public HttpResponse downloadFile(HttpRequest req, SessionId id) {
754768
}
755769
}
756770

771+
private String extractFileName(HttpRequest req) {
772+
return extractFileName(req.getUri());
773+
}
774+
775+
String extractFileName(String uri) {
776+
String prefix = "/se/files/";
777+
int index = uri.lastIndexOf(prefix);
778+
if (index < 0) {
779+
throw new IllegalArgumentException("Unexpected URL for downloading a file: " + uri);
780+
}
781+
return urlDecode(uri.substring(index + prefix.length())).replace(' ', '+');
782+
}
783+
757784
/** User wants to list files that can be downloaded */
758785
private HttpResponse listDownloadedFiles(File downloadsDirectory) {
759786
File[] files = Optional.ofNullable(downloadsDirectory.listFiles()).orElse(new File[] {});
@@ -798,29 +825,63 @@ private HttpResponse getDownloadedFile(HttpRequest req, File downloadsDirectory)
798825
new WebDriverException(
799826
"Please specify file to download in payload as {\"name\":"
800827
+ " \"fileToDownload\"}"));
801-
File[] allFiles =
802-
Optional.ofNullable(downloadsDirectory.listFiles((dir, name) -> name.equals(filename)))
803-
.orElse(new File[] {});
804-
if (allFiles.length == 0) {
805-
throw new WebDriverException(
806-
String.format(
807-
"Cannot find file [%s] in directory %s.",
808-
filename, downloadsDirectory.getAbsolutePath()));
809-
}
810-
if (allFiles.length != 1) {
811-
throw new WebDriverException(
812-
String.format("Expected there to be only 1 file. There were: %s.", allFiles.length));
813-
}
814-
String content = Zip.zip(allFiles[0]);
828+
File file = findDownloadedFile(downloadsDirectory, filename);
829+
String content = Zip.zip(file);
815830
Map<String, Object> data =
816831
Map.of(
817832
"filename", filename,
818-
"file", getFileInfo(allFiles[0]),
833+
"file", getFileInfo(file),
819834
"contents", content);
820835
Map<String, Map<String, Object>> result = Map.of("value", data);
821836
return new HttpResponse().setContent(asJson(result));
822837
}
823838

839+
private HttpResponse getDownloadedFile(File downloadsDirectory, String fileName)
840+
throws IOException {
841+
if (fileName.isEmpty()) {
842+
throw new WebDriverException("Please specify file to download in URL");
843+
}
844+
File file = findDownloadedFile(downloadsDirectory, fileName);
845+
BasicFileAttributes attributes = readAttributes(file.toPath(), BasicFileAttributes.class);
846+
return new HttpResponse()
847+
.setHeader("Content-Type", MediaType.OCTET_STREAM.toString())
848+
.setHeader("Content-Length", String.valueOf(attributes.size()))
849+
.setHeader("Last-Modified", lastModifiedHeader(attributes.lastModifiedTime()))
850+
.setContent(Contents.file(file));
851+
}
852+
853+
private String lastModifiedHeader(FileTime fileTime) {
854+
return HTTP_DATE_FORMAT.format(fileTime.toInstant().atZone(UTC));
855+
}
856+
857+
private File findDownloadedFile(File downloadsDirectory, String filename)
858+
throws WebDriverException {
859+
List<File> matchingFiles =
860+
asList(
861+
requireNonNullElseGet(
862+
downloadsDirectory.listFiles((dir, name) -> name.equals(filename)),
863+
() -> new File[0]));
864+
if (matchingFiles.isEmpty()) {
865+
List<File> files = downloadedFiles(downloadsDirectory);
866+
throw new WebDriverException(
867+
String.format(
868+
"Cannot find file [%s] in directory %s. Found %s files: %s.",
869+
filename, downloadsDirectory.getAbsolutePath(), files.size(), files));
870+
}
871+
if (matchingFiles.size() != 1) {
872+
throw new WebDriverException(
873+
String.format(
874+
"Expected there to be only 1 file. Found %s files: %s.",
875+
matchingFiles.size(), matchingFiles));
876+
}
877+
return matchingFiles.get(0);
878+
}
879+
880+
private static List<File> downloadedFiles(File downloadsDirectory) {
881+
File[] files = requireNonNullElseGet(downloadsDirectory.listFiles(), () -> new File[0]);
882+
return asList(files);
883+
}
884+
824885
private HttpResponse deleteDownloadedFile(File downloadsDirectory) {
825886
File[] files = Optional.ofNullable(downloadsDirectory.listFiles()).orElse(new File[] {});
826887
for (File file : files) {

java/src/org/openqa/selenium/net/Urls.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@
1717

1818
package org.openqa.selenium.net;
1919

20+
import static java.nio.charset.StandardCharsets.UTF_8;
21+
2022
import java.io.IOException;
2123
import java.io.UncheckedIOException;
2224
import java.net.MalformedURLException;
2325
import java.net.URI;
2426
import java.net.URISyntaxException;
2527
import java.net.URL;
28+
import java.net.URLDecoder;
2629
import java.net.URLEncoder;
27-
import java.nio.charset.StandardCharsets;
2830
import org.openqa.selenium.internal.Require;
2931

3032
public class Urls {
@@ -41,7 +43,11 @@ private Urls() {
4143
* @see URLEncoder#encode(java.lang.String, java.lang.String)
4244
*/
4345
public static String urlEncode(String value) {
44-
return URLEncoder.encode(value, StandardCharsets.UTF_8);
46+
return URLEncoder.encode(value, UTF_8);
47+
}
48+
49+
public static String urlDecode(String encodedValue) {
50+
return URLDecoder.decode(encodedValue, UTF_8);
4551
}
4652

4753
public static URL fromUri(URI uri) {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.openqa.selenium.netty.server;
19+
20+
import com.google.common.io.FileBackedOutputStream;
21+
import java.io.ByteArrayOutputStream;
22+
import java.io.IOException;
23+
import java.io.InputStream;
24+
import java.io.UncheckedIOException;
25+
import java.nio.charset.Charset;
26+
import org.openqa.selenium.remote.http.Contents;
27+
28+
class FileBackedOutputStreamContentSupplier implements Contents.Supplier {
29+
private final String description;
30+
private final FileBackedOutputStream buffer;
31+
private final long length;
32+
33+
FileBackedOutputStreamContentSupplier(
34+
String description, FileBackedOutputStream buffer, long length) {
35+
this.description = description;
36+
this.buffer = buffer;
37+
this.length = length;
38+
}
39+
40+
@Override
41+
public InputStream get() {
42+
try {
43+
return buffer.asByteSource().openBufferedStream();
44+
} catch (IOException e) {
45+
throw new UncheckedIOException(e);
46+
}
47+
}
48+
49+
@Override
50+
public long length() {
51+
return length;
52+
}
53+
54+
@Override
55+
public void close() throws IOException {
56+
buffer.reset();
57+
}
58+
59+
@Override
60+
public String toString() {
61+
return String.format("Content for %s (%s bytes)", description, length);
62+
}
63+
64+
@Override
65+
public String contentAsString(Charset charset) {
66+
ByteArrayOutputStream out = new ByteArrayOutputStream();
67+
try {
68+
buffer.asByteSource().copyTo(out);
69+
} catch (IOException e) {
70+
throw new RuntimeException(e);
71+
}
72+
return out.toString(charset);
73+
}
74+
}

0 commit comments

Comments
 (0)