Skip to content

Commit 5759002

Browse files
committed
New Session: stream values from local to remote end
With the number of protocols we speak, large capabilities (such as those that contain profile objects), are a recipe for running out of RAM. Try and mitigate this by streaming the capabilities from the local end to the remote end. Because of the way that we create the W3C capabilities, it's entirely possible this won't always work.
1 parent c6c3f95 commit 5759002

File tree

3 files changed

+290
-80
lines changed

3 files changed

+290
-80
lines changed

java/client/src/org/openqa/selenium/remote/ProtocolHandshake.java

Lines changed: 146 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@
3030
import static org.openqa.selenium.remote.BrowserType.SAFARI;
3131

3232
import com.google.common.base.Preconditions;
33-
import com.google.common.collect.ImmutableList;
3433
import com.google.common.collect.ImmutableSet;
34+
import com.google.gson.Gson;
35+
import com.google.gson.JsonArray;
36+
import com.google.gson.JsonElement;
37+
import com.google.gson.JsonObject;
38+
import com.google.gson.stream.JsonWriter;
3539

3640
import org.openqa.selenium.Capabilities;
3741
import org.openqa.selenium.SessionNotCreatedException;
@@ -40,49 +44,82 @@
4044
import org.openqa.selenium.remote.http.HttpRequest;
4145
import org.openqa.selenium.remote.http.HttpResponse;
4246

47+
import java.io.BufferedInputStream;
48+
import java.io.BufferedWriter;
4349
import java.io.IOException;
50+
import java.io.InputStream;
4451
import java.net.HttpURLConnection;
52+
import java.nio.file.Files;
53+
import java.nio.file.Path;
4554
import java.util.Collection;
4655
import java.util.HashMap;
47-
import java.util.List;
4856
import java.util.Map;
4957
import java.util.Optional;
5058
import java.util.Set;
59+
import java.util.logging.Level;
5160
import java.util.logging.Logger;
61+
import java.util.stream.Collector;
5262
import java.util.stream.Collectors;
5363
import java.util.stream.Stream;
5464

5565
public class ProtocolHandshake {
5666

5767
private final static Logger LOG = Logger.getLogger(ProtocolHandshake.class.getName());
5868

69+
/**
70+
* Capability names that should never be sent across the wire to a w3c compliant remote end.
71+
*/
72+
private final Set<String> UNUSED_W3C_NAMES = ImmutableSet.<String>builder()
73+
.add("firefox_binary")
74+
.add("firefox_profile")
75+
.add("marionette")
76+
.build();
77+
5978
public Result createSession(HttpClient client, Command command)
6079
throws IOException {
61-
// Avoid serialising the capabilities too many times. Things like profiles are expensive.
62-
6380
Capabilities desired = (Capabilities) command.getParameters().get("desiredCapabilities");
6481
desired = desired == null ? new DesiredCapabilities() : desired;
6582
Capabilities required = (Capabilities) command.getParameters().get("requiredCapabilities");
6683
required = required == null ? new DesiredCapabilities() : required;
6784

68-
String des = new BeanToJsonConverter().convert(desired);
69-
String req = new BeanToJsonConverter().convert(required);
70-
71-
// Assume the remote end obeys the robustness principle.
72-
StringBuilder parameters = new StringBuilder("{");
73-
amendW3cParameters(parameters, desired, required);
74-
parameters.append(",");
75-
amendGeckoDriver013Parameters(parameters, des, req);
76-
parameters.append(",");
77-
amendOssParameters(parameters, des, req);
78-
parameters.append("}");
79-
LOG.fine("Attempting multi-dialect session, assuming Postel's Law holds true on the remote end");
80-
Optional<Result> result = createSession(client, parameters);
81-
82-
if (result.isPresent()) {
83-
Result toReturn = result.get();
84-
LOG.info(String.format("Detected dialect: %s", toReturn.dialect));
85-
return toReturn;
85+
BeanToJsonConverter converter = new BeanToJsonConverter();
86+
JsonObject des = (JsonObject) converter.convertObject(desired);
87+
JsonObject req = (JsonObject) converter.convertObject(required);
88+
89+
// We don't know how large the generated JSON is going to be. Spool it to disk, and then read
90+
// the file size, then stream it to the remote end. If we could be sure the remote end could
91+
// cope with chunked requests we'd use those. I don't think we can. *sigh*
92+
Path jsonFile = Files.createTempFile("new-session", ".json");
93+
94+
try (
95+
BufferedWriter fileWriter = Files.newBufferedWriter(jsonFile, UTF_8);
96+
JsonWriter out = new JsonWriter(fileWriter)) {
97+
out.setHtmlSafe(true);
98+
out.setIndent(" ");
99+
Gson gson = new Gson();
100+
out.beginObject();
101+
102+
streamJsonWireProtocolParameters(out, gson, des, req);
103+
streamGeckoDriver013Parameters(out, gson, des, req);
104+
streamW3CProtocolParameters(out, gson, des, req);
105+
106+
out.endObject();
107+
out.flush();
108+
109+
long size = Files.size(jsonFile);
110+
try (InputStream rawIn = Files.newInputStream(jsonFile);
111+
BufferedInputStream contentStream = new BufferedInputStream(rawIn)) {
112+
LOG.fine("Attempting multi-dialect session, assuming Postel's Law holds true on the remote end");
113+
Optional<Result> result = createSession(client, contentStream, size);
114+
115+
if (result.isPresent()) {
116+
Result toReturn = result.get();
117+
LOG.info(String.format("Detected dialect: %s", toReturn.dialect));
118+
return toReturn;
119+
}
120+
}
121+
} finally {
122+
Files.deleteIfExists(jsonFile);
86123
}
87124

88125
throw new SessionNotCreatedException(
@@ -93,10 +130,22 @@ public Result createSession(HttpClient client, Command command)
93130
required));
94131
}
95132

96-
private void amendW3cParameters(
97-
StringBuilder parameters,
98-
Capabilities desired,
99-
Capabilities required) {
133+
private void streamJsonWireProtocolParameters(
134+
JsonWriter out,
135+
Gson gson,
136+
JsonObject des,
137+
JsonObject req) throws IOException {
138+
out.name("desiredCapabilities");
139+
gson.toJson(des, out);
140+
out.name("requiredCapabilities");
141+
gson.toJson(req, out);
142+
}
143+
144+
private void streamW3CProtocolParameters(
145+
JsonWriter out,
146+
Gson gson,
147+
JsonObject des,
148+
JsonObject req) throws IOException {
100149
// Technically we should be building up a combination of "alwaysMatch" and "firstMatch" options.
101150
// We're going to do a little processing to figure out what we might be able to do, and assume
102151
// that people don't really understand the difference between required and desired (which is
@@ -117,40 +166,37 @@ private void amendW3cParameters(
117166
// We can't use the constants defined in the classes because it would introduce circular
118167
// dependencies between the remote library and the implementations. Yay!
119168

120-
Map<String, ?> req = required.asMap();
121-
Map<String, ?> des = desired.asMap();
122-
123169
Map<String, ?> chrome = Stream.of(des, req)
124-
.map(Map::entrySet)
170+
.map(JsonObject::entrySet)
125171
.flatMap(Collection::stream)
126172
.filter(entry ->
127-
("browserName".equals(entry.getKey()) && CHROME.equals(entry.getValue())) ||
173+
("browserName".equals(entry.getKey()) && CHROME.equals(entry.getValue().getAsString())) ||
128174
"chromeOptions".equals(entry.getKey()))
129175
.distinct()
130176
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
131177

132178
Map<String, ?> edge = Stream.of(des, req)
133-
.map(Map::entrySet)
179+
.map(JsonObject::entrySet)
134180
.flatMap(Collection::stream)
135-
.filter(entry -> ("browserName".equals(entry.getKey()) && EDGE.equals(entry.getValue())))
181+
.filter(entry -> ("browserName".equals(entry.getKey()) && EDGE.equals(entry.getValue().getAsString())))
136182
.distinct()
137183
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
138184

139185
Map<String, ?> firefox = Stream.of(des, req)
140-
.map(Map::entrySet)
186+
.map(JsonObject::entrySet)
141187
.flatMap(Collection::stream)
142188
.filter(entry ->
143-
("browserName".equals(entry.getKey()) && FIREFOX.equals(entry.getValue())) ||
189+
("browserName".equals(entry.getKey()) && FIREFOX.equals(entry.getValue().getAsString())) ||
144190
entry.getKey().startsWith("firefox_") ||
145191
entry.getKey().startsWith("moz:"))
146192
.distinct()
147193
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
148194

149195
Map<String, ?> ie = Stream.of(req, des)
150-
.map(Map::entrySet)
196+
.map(JsonObject::entrySet)
151197
.flatMap(Collection::stream)
152198
.filter(entry ->
153-
("browserName".equals(entry.getKey()) && IE.equals(entry.getValue())) ||
199+
("browserName".equals(entry.getKey()) && IE.equals(entry.getValue().getAsString())) ||
154200
"browserAttachTimeout".equals(entry.getKey()) ||
155201
"enableElementCacheCleanup".equals(entry.getKey()) ||
156202
"enablePersistentHover".equals(entry.getKey()) ||
@@ -167,20 +213,20 @@ private void amendW3cParameters(
167213
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
168214

169215
Map<String, ?> opera = Stream.of(des, req)
170-
.map(Map::entrySet)
216+
.map(JsonObject::entrySet)
171217
.flatMap(Collection::stream)
172218
.filter(entry ->
173-
("browserName".equals(entry.getKey()) && OPERA_BLINK.equals(entry.getValue())) ||
174-
("browserName".equals(entry.getKey()) && OPERA.equals(entry.getValue())) ||
219+
("browserName".equals(entry.getKey()) && OPERA_BLINK.equals(entry.getValue().getAsString())) ||
220+
("browserName".equals(entry.getKey()) && OPERA.equals(entry.getValue().getAsString())) ||
175221
"operaOptions".equals(entry.getKey()))
176222
.distinct()
177223
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
178224

179225
Map<String, ?> safari = Stream.of(des, req)
180-
.map(Map::entrySet)
226+
.map(JsonObject::entrySet)
181227
.flatMap(Collection::stream)
182228
.filter(entry ->
183-
("browserName".equals(entry.getKey()) && SAFARI.equals(entry.getValue())) ||
229+
("browserName".equals(entry.getKey()) && SAFARI.equals(entry.getValue().getAsString())) ||
184230
"safari.options".equals(entry.getKey()))
185231
.distinct()
186232
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
@@ -191,35 +237,62 @@ private void amendW3cParameters(
191237
.distinct()
192238
.collect(ImmutableSet.toImmutableSet());
193239

194-
Map<String, ?> alwaysMatch = Stream.of(des, req)
195-
.map(Map::entrySet)
240+
JsonObject alwaysMatch = Stream.of(des, req)
241+
.map(JsonObject::entrySet)
196242
.flatMap(Collection::stream)
197243
.filter(entry -> !excludedKeys.contains(entry.getKey()))
198244
.filter(entry -> entry.getValue() != null)
199-
.filter(entry -> !"marionette".equals(entry.getKey())) // We never want to send this
245+
.filter(entry -> !UNUSED_W3C_NAMES.contains(entry.getKey())) // We never want to send this
200246
.distinct()
201-
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
247+
.collect(Collector.of(
248+
JsonObject::new,
249+
(obj, e) -> obj.add(e.getKey(), e.getValue()),
250+
(left, right) -> {
251+
for (Map.Entry<String, JsonElement> entry : right.entrySet()) {
252+
left.add(entry.getKey(), entry.getValue());
253+
}
254+
return left;
255+
}));
202256

203257
// Now, hopefully we're left with just the browser-specific pieces. Skip the empty ones.
204-
List<Map<String, ?>> firstMatch = Stream.of(chrome, edge, firefox, ie, opera, safari)
258+
JsonArray firstMatch = Stream.of(chrome, edge, firefox, ie, opera, safari)
205259
.filter(map -> !map.isEmpty())
206-
.collect(ImmutableList.toImmutableList());
207-
208-
BeanToJsonConverter converter = new BeanToJsonConverter();
209-
parameters.append("\"alwaysMatch\": ").append(converter.convert(alwaysMatch)).append(",");
210-
parameters.append("\"firstMatch\": ").append(converter.convert(firstMatch));
260+
.map(map -> {
261+
JsonObject json = new JsonObject();
262+
for (Map.Entry<String, ?> entry : map.entrySet()) {
263+
if (!UNUSED_W3C_NAMES.contains(entry.getKey())) {
264+
json.add(entry.getKey(), gson.toJsonTree(entry.getValue()));
265+
}
266+
}
267+
return json;
268+
})
269+
.collect(Collector.of(
270+
JsonArray::new,
271+
JsonArray::add,
272+
(left, right) -> {
273+
for (JsonElement element : right) {
274+
left.add(element);
275+
}
276+
return left;
277+
}
278+
));
279+
280+
// TODO(simon): transform some capabilities that changed in the spec (timeout's "pageLoad")
281+
282+
out.name("alwaysMatch");
283+
gson.toJson(alwaysMatch, out);
284+
out.name("firstMatch");
285+
gson.toJson(firstMatch, out);
211286
}
212287

213-
private Optional<Result> createSession(HttpClient client, StringBuilder params)
288+
private Optional<Result> createSession(HttpClient client, InputStream newSessionBlob, long size)
214289
throws IOException {
215290
// Create the http request and send it
216291
HttpRequest request = new HttpRequest(HttpMethod.POST, "/session");
217-
String content = params.toString();
218-
byte[] data = content.getBytes(UTF_8);
219292

220-
request.setHeader(CONTENT_LENGTH, String.valueOf(data.length));
293+
request.setHeader(CONTENT_LENGTH, String.valueOf(size));
221294
request.setHeader(CONTENT_TYPE, JSON_UTF_8.toString());
222-
request.setContent(data);
295+
request.setContent(newSessionBlob);
223296
HttpResponse response = client.execute(request, true);
224297

225298
Map<?, ?> jsonBlob = null;
@@ -231,6 +304,10 @@ private Optional<Result> createSession(HttpClient client, StringBuilder params)
231304
return Optional.empty();
232305
} catch (JsonException e) {
233306
// Fine. Handle that below
307+
LOG.log(
308+
Level.FINE,
309+
"Unable to parse json response. Will continue but diagnostic follows",
310+
e);
234311
}
235312

236313
if (jsonBlob == null) {
@@ -292,27 +369,20 @@ private Optional<Result> createSession(HttpClient client, StringBuilder params)
292369
return Optional.empty();
293370
}
294371

295-
private void amendGeckoDriver013Parameters(
296-
StringBuilder params,
297-
String desired,
298-
String required) {
299-
params.append("\"capabilities\": {");
300-
params.append("\"desiredCapabilities\": ").append(desired);
301-
params.append(",");
302-
params.append("\"requiredCapabilities\": ").append(required);
303-
params.append("}");
372+
private void streamGeckoDriver013Parameters(
373+
JsonWriter out,
374+
Gson gson,
375+
JsonObject des,
376+
JsonObject req) throws IOException {
377+
out.name("capabilities");
378+
out.beginObject();
379+
out.name("desiredCapabilities");
380+
gson.toJson(des, out);
381+
out.name("requiredCapabilities");
382+
gson.toJson(req, out);
383+
out.endObject(); // End "capabilities"
304384
}
305385

306-
private void amendOssParameters(
307-
StringBuilder params,
308-
String desired,
309-
String required) {
310-
params.append("\"desiredCapabilities\": ").append(desired);
311-
params.append(",");
312-
params.append("\"requiredCapabilities\": ").append(required);
313-
}
314-
315-
316386
public class Result {
317387
private final Dialect dialect;
318388
private final Map<String, ?> capabilities;

0 commit comments

Comments
 (0)