Repository: jhy/jsoup
Pull request #1476 (2021-01-16 06:29:08)

Adds easy request session-state and corrects cookie domain and path rules
This extends the Connection implementation to support (optional) sessions, which allow request defaults (timeout, proxy, etc) to be set once and then applied to all requests within that session. 

Cookies are re-implemented to correctly support path and domain filtering when used within a session. A default in-memory cookie store is used for the session, or a custom implementation (perhaps disk-persistent, or pre-set) can be used instead.

Forms submitted using the `FormElement#submit()` use the same session that was used to fetch the document and so pass cookies and other defaults appropriately.

The session is multi-thread safe and can execute multiple requests concurrently. If the user accidently tries to execute the same request object across multiple threads (vs calling `Connection#newRequest()`), that is detected cleanly and a clear exception is thrown (vs weird blowups in input stream reading, or forcing everything throw a synchronized bottleneck.

Examples:
```java
Connection session = Jsoup.newSession()
     .timeout(20 * 1000)
     .userAgent("FooBar 2000");

Document doc1 = session.newRequest()
     .url("https://jsoup.org/").data("ref", "example")
     .get();
Document doc2 = session.newRequest()
     .url("https://en.wikipedia.org/wiki/Main_Page")
     .get();
Connection con3 = session.newRequest();
```


Patch:
diff --git a/pom.xml b/pom.xml
index e1a23795..bc1cfed9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -203,11 +203,19 @@
             <onlyModified>false</onlyModified>
             <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
             <breakBuildOnSourceIncompatibleModifications>true</breakBuildOnSourceIncompatibleModifications>
+            <overrideCompatibilityChangeParameters>
+              <!-- 1.14.1 interface adds for cookies. rarely implemented outside of jsoup, and can't provide default impl in 7, so flag in changelog -->
+              <overrideCompatibilityChangeParameter>
+                <compatibilityChange>METHOD_ADDED_TO_INTERFACE</compatibilityChange>
+                <binaryCompatible>true</binaryCompatible>
+                <sourceCompatible>true</sourceCompatible>
+              </overrideCompatibilityChangeParameter>
+            </overrideCompatibilityChangeParameters>
           </parameter>
         </configuration>
         <executions>
           <execution>
-            <phase>verify</phase>
+            <phase>package</phase>
             <goals>
               <goal>cmp</goal>
             </goals>
diff --git a/src/main/java/org/jsoup/Connection.java b/src/main/java/org/jsoup/Connection.java
index 841e0e27..ef64faa4 100644
--- a/src/main/java/org/jsoup/Connection.java
+++ b/src/main/java/org/jsoup/Connection.java
@@ -8,6 +8,7 @@ import javax.net.ssl.SSLSocketFactory;
 import java.io.BufferedInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.CookieStore;
 import java.net.Proxy;
 import java.net.URL;
 import java.util.Collection;
@@ -15,16 +16,25 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * The Connection interface is a convenient HTTP client to fetch content from the web, and parse them into Documents.
- * <p>
- * To get a new Connection, use {@link org.jsoup.Jsoup#connect(String)}. Connections contain {@link Connection.Request}
- * and {@link Connection.Response} objects. The request objects are reusable as prototype requests.
- * </p>
- * <p>
- * Request configuration can be made using either the shortcut methods in Connection (e.g. {@link #userAgent(String)}),
- * or by methods in the Connection.Request object directly. All request configuration must be made before the request is
- * executed.
- * </p>
+ The Connection interface is a convenient HTTP client and session object to fetch content from the web, and parse them
+ into Documents.
+ <p>To start a new session, use either {@link org.jsoup.Jsoup#newSession()} or {@link org.jsoup.Jsoup#connect(String)}.
+ Connections contain {@link Connection.Request} and {@link Connection.Response} objects (once executed). Configuration
+ settings (URL, timeout, useragent, etc) set on a session will be applied by default to each subsequent request.</p>
+ <p>To start a new request from the session, use {@link #newRequest()}.</p>
+ <p>Cookies are stored in memory for the duration of the session. For that reason, do not use one single session for all
+ requests in a long-lived application, or you are likely to run out of memory, unless care is taken to clean up the
+ cookie store. The cookie store for the session is available via {@link #cookieStore()}. You may provide your own
+ implementation via {@link #cookieStore(java.net.CookieStore)} before making requests.</p>
+ <p>Request configuration can be made using either the shortcut methods in Connection (e.g. {@link #userAgent(String)}),
+ or by methods in the Connection.Request object directly. All request configuration must be made before the request is
+ executed. When used as an ongoing session, initialize all defaults prior to making multi-threaded {@link
+#newRequest()}s.</p>
+ <p>Note that the term "Connection" used here does not mean that a long-lived connection is held against a server for
+ the lifetime of the Connection object. A socket connection is only made at the point of request execution ({@link
+#execute()}, {@link #get()}, or {@link #post()}), and the server's response consumed.</p>
+ <p>For multi-threaded implementations, it is important to use a {@link #newRequest()} for each request. The session may
+ be shared across threads but a given request, not.</p>
  */
 @SuppressWarnings("unused")
 public interface Connection {
@@ -50,6 +60,13 @@ public interface Connection {
         }
     }
 
+    /**
+     Creates a new request, using this Connection as the session-state and to initialize the connection settings (which may then be independently on the returned Connection.Request object).
+     @return a new Connection object, with a shared Cookie Store and initialized settings from this Connection and Request
+     @since 1.14.1
+     */
+    Connection newRequest();
+
     /**
      * Set the request URL to fetch. The protocol must be HTTP or HTTPS.
      * @param url URL to connect to
@@ -206,11 +223,15 @@ public interface Connection {
     Connection data(Map<String, String> data);
 
     /**
-     * Add a number of request data parameters. Multiple parameters may be set at once, e.g.: <code>.data("name",
-     * "jsoup", "language", "Java", "language", "English");</code> creates a query string like:
-     * <code>{@literal ?name=jsoup&language=Java&language=English}</code>
-     * @param keyvals a set of key value pairs.
-     * @return this Connection, for chaining
+     Add one or more request {@code key, val} data parameter pairs.<p>Multiple parameters may be set at once, e.g.:
+     <code>.data("name", "jsoup", "language", "Java", "language", "English");</code> creates a query string like:
+     <code>{@literal ?name=jsoup&language=Java&language=English}</code></p>
+     <p>For GET requests, data parameters will be sent on the request query string. For POST (and other methods that
+     contain a body), they will be sent as body form parameters, unless the body is explicitly set by {@link
+    #requestBody(String)}, in which case they will be query string parameters.</p>
+
+     @param keyvals a set of key value pairs.
+     @return this Connection, for chaining
      */
     Connection data(String... keyvals);
 
@@ -265,6 +286,21 @@ public interface Connection {
      */
     Connection cookies(Map<String, String> cookies);
 
+    /**
+     Provide a custom or pre-filled CookieStore to be used on requests made by this Connection.
+     @param cookieStore a cookie store to use for subsequent requests
+     @return this Connection, for chaining
+     @since 1.14.1
+     */
+    Connection cookieStore(CookieStore cookieStore);
+
+    /**
+     Get the cookie store used by this Connection.
+     @return the cookie store
+     @since 1.14.1
+     */
+    CookieStore cookieStore();
+
     /**
      * Provide an alternate parser to use when parsing the response to a Document. If not set, defaults to the HTML
      * parser, unless the response content-type is XML, in which case the XML parser is used.
diff --git a/src/main/java/org/jsoup/HttpStatusException.java b/src/main/java/org/jsoup/HttpStatusException.java
index 2ad83b7d..3e98aa63 100644
--- a/src/main/java/org/jsoup/HttpStatusException.java
+++ b/src/main/java/org/jsoup/HttpStatusException.java
@@ -10,7 +10,7 @@ public class HttpStatusException extends IOException {
     private final String url;
 
     public HttpStatusException(String message, int statusCode, String url) {
-        super(message);
+        super(message + ". Status=" + statusCode + ", URL=[" + url + "]");
         this.statusCode = statusCode;
         this.url = url;
     }
@@ -22,9 +22,4 @@ public class HttpStatusException extends IOException {
     public String getUrl() {
         return url;
     }
-
-    @Override
-    public String toString() {
-        return super.toString() + ". Status=" + statusCode + ", URL=" + url;
-    }
 }
diff --git a/src/main/java/org/jsoup/Jsoup.java b/src/main/java/org/jsoup/Jsoup.java
index ff74f7a3..3eb7f0b5 100644
--- a/src/main/java/org/jsoup/Jsoup.java
+++ b/src/main/java/org/jsoup/Jsoup.java
@@ -62,7 +62,7 @@ public class Jsoup {
     }
 
     /**
-     * Creates a new {@link Connection} to a URL. Use to fetch and parse a HTML page.
+     * Creates a new {@link Connection} (session), with the defined request URL. Use to fetch and parse a HTML page.
      * <p>
      * Use examples:
      * <ul>
@@ -71,11 +71,40 @@ public class Jsoup {
      * </ul>
      * @param url URL to connect to. The protocol must be {@code http} or {@code https}.
      * @return the connection. You can add data, cookies, and headers; set the user-agent, referrer, method; and then execute.
+     * @see #newSession()
+     * @see Connection#newRequest()
      */
     public static Connection connect(String url) {
         return HttpConnection.connect(url);
     }
 
+    /**
+     Creates a new {@link Connection} to use as a session. Connection settings (user-agent, timeouts, URL, etc), and
+     cookies will be maintained for the session. Use examples:
+<pre><code>
+Connection session = Jsoup.newSession()
+     .timeout(20 * 1000)
+     .userAgent("FooBar 2000");
+
+Document doc1 = session.newRequest()
+     .url("https://jsoup.org/").data("ref", "example")
+     .get();
+Document doc2 = session.newRequest()
+     .url("https://en.wikipedia.org/wiki/Main_Page")
+     .get();
+Connection con3 = session.newRequest();
+</code></pre>
+
+     <p>For multi-threaded requests, it is safe to use this session between threads, but take care to call {@link
+    Connection#newRequest()} per request and not share that instance between threads when executing or parsing.</p>
+
+     @return a connection
+     @since 1.14.1
+     */
+    public static Connection newSession() {
+        return new HttpConnection();
+    }
+
     /**
      Parse the contents of a file as HTML.
 
diff --git a/src/main/java/org/jsoup/helper/CookieUtil.java b/src/main/java/org/jsoup/helper/CookieUtil.java
new file mode 100644
index 00000000..f3750037
--- /dev/null
+++ b/src/main/java/org/jsoup/helper/CookieUtil.java
@@ -0,0 +1,90 @@
+package org.jsoup.helper;
+
+import org.jsoup.Connection;
+import org.jsoup.internal.StringUtil;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ Helper functions to support the Cookie Manager / Cookie Storage in HttpConnection.
+
+ @since 1.14.1 */
+class CookieUtil {
+    // cookie manager get() wants request headers but doesn't use them, so we just pass a dummy object here
+    private static final Map<String, List<String>> EmptyRequestHeaders = Collections.unmodifiableMap(new HashMap<>());
+    private static final String Sep = "; ";
+    private static final String CookieName = "Cookie";
+    private static final String Cookie2Name = "Cookie2";
+
+    /**
+     Pre-request, get any applicable headers out of the Request cookies and the Cookie Store, and add them to the request
+     headers. If the Cookie Store duplicates any Request cookies (same name and value), they will be discarded.
+     */
+    static void applyCookiesToRequest(HttpConnection.Request req, HttpURLConnection con) throws IOException {
+        // Request key/val cookies. LinkedHashSet used to preserve order, as cookie store will return most specific path first
+        Set<String> cookieSet = requestCookieSet(req);
+        Set<String> cookies2 = null;
+
+        // stored:
+        Map<String, List<String>> storedCookies = req.cookieManager().get(asUri(req.url), EmptyRequestHeaders);
+        for (Map.Entry<String, List<String>> entry : storedCookies.entrySet()) {
+            // might be Cookie: name=value; name=value\nCookie2: name=value; name=value
+            List<String> cookies = entry.getValue(); // these will be name=val
+            if (cookies == null || cookies.size() == 0) // the cookie store often returns just an empty "Cookie" key, no val
+                continue;
+
+            String key = entry.getKey(); // Cookie or Cookie2
+            Set<String> set;
+            if (CookieName.equals(key))
+                set = cookieSet;
+            else if (Cookie2Name.equals(key)) {
+                set = new HashSet<>();
+                cookies2 = set;
+            } else {
+                continue; // unexpected header key
+            }
+            set.addAll(cookies);
+        }
+
+        if (cookieSet.size() > 0)
+            con.addRequestProperty(CookieName, StringUtil.join(cookieSet, Sep));
+        if (cookies2 != null && cookies2.size() > 0)
+            con.addRequestProperty(Cookie2Name, StringUtil.join(cookies2, Sep));
+    }
+
+    private static LinkedHashSet<String> requestCookieSet(Connection.Request req) {
+        LinkedHashSet<String> set = new LinkedHashSet<>();
+        // req cookies are the wildcard key/val cookies (no domain, path, etc)
+        for (Map.Entry<String, String> cookie : req.cookies().entrySet()) {
+            set.add(cookie.getKey() + "=" + cookie.getValue());
+        }
+        return set;
+    }
+
+    static URI asUri(URL url) throws IOException {
+        try {
+            return url.toURI();
+        } catch (URISyntaxException e) {  // this would be a WTF because we construct the URL
+            MalformedURLException ue = new MalformedURLException(e.getMessage());
+            ue.initCause(e);
+            throw ue;
+        }
+    }
+
+    static void storeCookies(HttpConnection.Request req, URL url, Map<String, List<String>> resHeaders) throws IOException {
+        req.cookieManager().put(CookieUtil.asUri(url), resHeaders); // stores cookies for session
+
+    }
+}
diff --git a/src/main/java/org/jsoup/helper/DataUtil.java b/src/main/java/org/jsoup/helper/DataUtil.java
index 7806bd0e..406ea5bd 100644
--- a/src/main/java/org/jsoup/helper/DataUtil.java
+++ b/src/main/java/org/jsoup/helper/DataUtil.java
@@ -36,6 +36,7 @@ import java.util.zip.GZIPInputStream;
  * Internal static utilities for handling data.
  *
  */
+@SuppressWarnings("CharsetObjectCanBeUsed")
 public final class DataUtil {
     private static final Pattern charsetPattern = Pattern.compile("(?i)\\bcharset=\\s*(?:[\"'])?([^\\s,;\"']*)");
     public static final Charset UTF_8 = Charset.forName("UTF-8"); // Don't use StandardCharsets, as those only appear in Android API 19, and we target 10.
@@ -59,7 +60,7 @@ public final class DataUtil {
      * @return Document
      * @throws IOException on IO error
      */
-    public static Document load(File in, String charsetName, String baseUri) throws IOException {
+    public static Document load(File in, @Nullable String charsetName, String baseUri) throws IOException {
         InputStream stream = new FileInputStream(in);
         String name = Normalizer.lowerCase(in.getName());
         if (name.endsWith(".gz") || name.endsWith(".z")) {
@@ -264,7 +265,7 @@ public final class DataUtil {
     }
 
     private static @Nullable BomCharset detectCharsetFromBom(final ByteBuffer byteData) {
-        final Buffer buffer = byteData; // .mark and rewind used to return Buffer, now ByteBuffer, so cast for backward compat
+        @SuppressWarnings("UnnecessaryLocalVariable") final Buffer buffer = byteData; // .mark and rewind used to return Buffer, now ByteBuffer, so cast for backward compat
         buffer.mark();
         byte[] bom = new byte[4];
         if (byteData.remaining() >= bom.length) {
diff --git a/src/main/java/org/jsoup/helper/HttpConnection.java b/src/main/java/org/jsoup/helper/HttpConnection.java
index 41565c6c..7914803a 100644
--- a/src/main/java/org/jsoup/helper/HttpConnection.java
+++ b/src/main/java/org/jsoup/helper/HttpConnection.java
@@ -20,6 +20,8 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.net.CookieManager;
+import java.net.CookieStore;
 import java.net.HttpURLConnection;
 import java.net.IDN;
 import java.net.InetSocketAddress;
@@ -69,12 +71,22 @@ public class HttpConnection implements Connection {
     private static final Charset UTF_8 = Charset.forName("UTF-8"); // Don't use StandardCharsets, not in Android API 10.
     private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
 
+    /**
+     Create a new Connection, with the request URL specified.
+     @param url the URL to fetch from
+     @return a new Connection object
+     */
     public static Connection connect(String url) {
         Connection con = new HttpConnection();
         con.url(url);
         return con;
     }
 
+    /**
+     Create a new Connection, with the request URL specified.
+     @param url the URL to fetch from
+     @return a new Connection object
+     */
     public static Connection connect(URL url) {
         Connection con = new HttpConnection();
         con.url(url);
@@ -88,6 +100,14 @@ public class HttpConnection implements Connection {
         req = new Request();
     }
 
+    /**
+     Create a new Request by deep-copying an existing Request
+     @param copy the request to copy
+     */
+    HttpConnection(Request copy) {
+        req = new Request(copy);
+    }
+
     /**
      * Encodes the input URL into a safe ASCII URL string
      * @param url unescaped URL
@@ -138,9 +158,21 @@ public class HttpConnection implements Connection {
         return val.replace("\"", "%22");
     }
 
-    private Connection.Request req;
+    private HttpConnection.Request req;
     private @Nullable Connection.Response res;
 
+    @Override
+    public Connection newRequest() {
+        // copy the prototype request for the different settings, cookie manager, etc
+        return new HttpConnection(req);
+    }
+
+    /** Create a new Connection that just wraps the provided Request and Response */
+    private HttpConnection(Request req, Response res) {
+        this.req = req;
+        this.res = res;
+    }
+
     public Connection url(URL url) {
         req.url(url);
         return this;
@@ -299,6 +331,18 @@ public class HttpConnection implements Connection {
         return this;
     }
 
+    @Override
+    public Connection cookieStore(CookieStore cookieStore) {
+        // create a new cookie manager using the new store
+        req.cookieManager = new CookieManager(cookieStore, null);
+        return this;
+    }
+
+    @Override
+    public CookieStore cookieStore() {
+        return req.cookieManager.getCookieStore();
+    }
+
     public Connection parser(Parser parser) {
         req.parser(parser);
         return this;
@@ -328,7 +372,7 @@ public class HttpConnection implements Connection {
     }
 
     public Connection request(Connection.Request request) {
-        req = request;
+        req = (HttpConnection.Request) request; // will throw a class-cast exception if the user has extended some but not all of Connection; that's desired
         return this;
     }
 
@@ -371,9 +415,19 @@ public class HttpConnection implements Connection {
             cookies = new LinkedHashMap<>();
         }
 
+        private Base(Base<T> copy) {
+            url = copy.url; // unmodifiable object
+            method = copy.method;
+            headers = new LinkedHashMap<>();
+            for (Map.Entry<String, List<String>> entry : copy.headers.entrySet()) {
+                headers.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+            }
+            cookies = new LinkedHashMap<>(); cookies.putAll(copy.cookies); // just holds strings
+        }
+
         public URL url() {
             if (url == UnsetUrl)
-                throw new IllegalArgumentException("URL not yet set");
+                throw new IllegalArgumentException("URL not set. Make sure to call #url(...) before executing the request.");
             return url;
         }
 
@@ -576,6 +630,7 @@ public class HttpConnection implements Connection {
             System.setProperty("sun.net.http.allowRestrictedHeaders", "true");
             // make sure that we can send Sec-Fetch-Site headers etc.
         }
+
         private @Nullable Proxy proxy;
         private int timeoutMilliseconds;
         private int maxBodySizeBytes;
@@ -588,8 +643,11 @@ public class HttpConnection implements Connection {
         private boolean parserDefined = false; // called parser(...) vs initialized in ctor
         private String postDataCharset = DataUtil.defaultCharsetName;
         private @Nullable SSLSocketFactory sslSocketFactory;
+        private CookieManager cookieManager;
+        private volatile boolean executing = false;
 
         Request() {
+            super();
             timeoutMilliseconds = 30000; // 30 seconds
             maxBodySizeBytes = 1024 * 1024 * 2; // 2MB
             followRedirects = true;
@@ -598,6 +656,25 @@ public class HttpConnection implements Connection {
             addHeader("Accept-Encoding", "gzip");
             addHeader(USER_AGENT, DEFAULT_UA);
             parser = Parser.htmlParser();
+            cookieManager = new CookieManager(); // creates a default InMemoryCookieStore
+        }
+
+        Request(Request copy) {
+            super(copy);
+            proxy = copy.proxy;
+            postDataCharset = copy.postDataCharset;
+            timeoutMilliseconds = copy.timeoutMilliseconds;
+            maxBodySizeBytes = copy.maxBodySizeBytes;
+            followRedirects = copy.followRedirects;
+            data = new ArrayList<>(); data.addAll(copy.data()); // this is shallow, but holds immutable string keyval, and possibly an InputStream which can only be read once anyway, so using as a prototype would be unsupported
+            body = copy.body;
+            ignoreHttpErrors = copy.ignoreHttpErrors;
+            ignoreContentType = copy.ignoreContentType;
+            parser = copy.parser.newInstance(); // parsers and their tree-builders maintain state, so need a fresh copy
+            parserDefined = copy.parserDefined;
+            sslSocketFactory = copy.sslSocketFactory; // these are all synchronized so safe to share
+            cookieManager = copy.cookieManager;
+            executing = false;
         }
 
         public Proxy proxy() {
@@ -708,6 +785,10 @@ public class HttpConnection implements Connection {
         public String postDataCharset() {
             return postDataCharset;
         }
+
+        CookieManager cookieManager() {
+            return cookieManager;
+        }
     }
 
     public static class Response extends HttpConnection.Base<Connection.Response> implements Connection.Response {
@@ -723,7 +804,7 @@ public class HttpConnection implements Connection {
         private boolean executed = false;
         private boolean inputStreamRead = false;
         private int numRedirects = 0;
-        private final Connection.Request req;
+        private final HttpConnection.Request req;
 
         /*
          * Matches XML content types (like text/xml, application/xhtml+xml;charset=UTF8, etc)
@@ -742,11 +823,15 @@ public class HttpConnection implements Connection {
             contentType = null;
         }
 
-        static Response execute(Connection.Request req) throws IOException {
+        static Response execute(HttpConnection.Request req) throws IOException {
             return execute(req, null);
         }
 
-        static Response execute(Connection.Request req, @Nullable Response previousResponse) throws IOException {
+        static Response execute(HttpConnection.Request req, @Nullable Response previousResponse) throws IOException {
+            synchronized (req) {
+                Validate.isFalse(req.executing, "Multiple threads were detected trying to execute the same request concurrently. Make sure to use Connection#newRequest() and do not share an executing request between threads.");
+                req.executing = true;
+            }
             Validate.notNull(req, "Request must not be null");
             URL url = req.url();
             Validate.notNull(url, "URL must be specified to connect");
@@ -792,9 +877,7 @@ public class HttpConnection implements Connection {
                     URL redir = StringUtil.resolve(req.url(), location);
                     req.url(encodeUrl(redir));
 
-                    for (Map.Entry<String, String> cookie : res.cookies.entrySet()) { // add response cookies to request (for e.g. login posts)
-                        req.cookie(cookie.getKey(), cookie.getValue());
-                    }
+                    req.executing = false;
                     return execute(req, res);
                 }
                 if ((status < 200 || status >= 400) && !req.ignoreHttpErrors())
@@ -812,10 +895,7 @@ public class HttpConnection implements Connection {
 
                 // switch to the XML parser if content type is xml and not parser not explicitly set
                 if (contentType != null && xmlContentTypeRxp.matcher(contentType).matches()) {
-                    // only flip it if a HttpConnection.Request (i.e. don't presume other impls want it):
-                    if (req instanceof HttpConnection.Request && !((Request) req).parserDefined) {
-                        req.parser(Parser.xmlParser());
-                    }
+                    if (!req.parserDefined) req.parser(Parser.xmlParser());
                 }
 
                 res.charset = DataUtil.getCharsetFromContentType(res.contentType); // may be null, readInputStream deals with it
@@ -837,6 +917,8 @@ public class HttpConnection implements Connection {
             } catch (IOException e) {
                 if (res != null) res.safeClose(); // will be non-null if got to conn
                 throw e;
+            } finally {
+                req.executing = false;
             }
 
             res.executed = true;
@@ -872,6 +954,7 @@ public class HttpConnection implements Connection {
             }
             Validate.isFalse(inputStreamRead, "Input stream already read and parsed, cannot re-read.");
             Document doc = DataUtil.parseInputStream(bodyStream, charset, url.toExternalForm(), req.parser());
+            doc.connection(new HttpConnection(req, this)); // because we're static, don't have the connection obj. // todo - maybe hold in the req?
             charset = doc.outputSettings().charset().name(); // update charset from meta-equiv, possibly
             inputStreamRead = true;
             safeClose();
@@ -924,7 +1007,7 @@ public class HttpConnection implements Connection {
         }
 
         // set up connection defaults, and details from request
-        private static HttpURLConnection createConnection(Connection.Request req) throws IOException {
+        private static HttpURLConnection createConnection(HttpConnection.Request req) throws IOException {
             Proxy proxy = req.proxy();
             final HttpURLConnection conn = (HttpURLConnection) (
                 proxy == null ?
@@ -941,8 +1024,7 @@ public class HttpConnection implements Connection {
                 ((HttpsURLConnection) conn).setSSLSocketFactory(req.sslSocketFactory());
             if (req.method().hasBody())
                 conn.setDoOutput(true);
-            if (req.cookies().size() > 0)
-                conn.addRequestProperty("Cookie", getRequestCookieString(req));
+            CookieUtil.applyCookiesToRequest(req, conn); // from the Request key/val cookies and the Cookie Store
             for (Map.Entry<String, List<String>> header : req.multiHeaders().entrySet()) {
                 for (String value : header.getValue()) {
                     conn.addRequestProperty(header.getKey(), value);
@@ -972,7 +1054,7 @@ public class HttpConnection implements Connection {
         }
 
         // set up url, method, header, cookies
-        private Response(HttpURLConnection conn, Connection.Request request, @Nullable HttpConnection.Response previousResponse) throws IOException {
+        private Response(HttpURLConnection conn, HttpConnection.Request request, @Nullable HttpConnection.Response previousResponse) throws IOException {
             this.conn = conn;
             this.req = request;
             method = Method.valueOf(conn.getRequestMethod());
@@ -982,10 +1064,11 @@ public class HttpConnection implements Connection {
             contentType = conn.getContentType();
 
             Map<String, List<String>> resHeaders = createHeaderMap(conn);
-            processResponseHeaders(resHeaders);
+            processResponseHeaders(resHeaders); // includes cookie key/val read during header scan
+            CookieUtil.storeCookies(req, url, resHeaders); // add set cookies to cookie store
 
-            // if from a redirect, map previous response cookies into this response
-            if (previousResponse != null) {
+            if (previousResponse != null) { // was redirected
+                // map previous response cookies into this response cookies() object
                 for (Map.Entry<String, String> prevCookie : previousResponse.cookies().entrySet()) {
                     if (!hasCookie(prevCookie.getKey()))
                         cookie(prevCookie.getKey(), prevCookie.getValue());
@@ -1037,9 +1120,9 @@ public class HttpConnection implements Connection {
                         TokenQueue cd = new TokenQueue(value);
                         String cookieName = cd.chompTo("=").trim();
                         String cookieVal = cd.consumeTo(";").trim();
-                        // ignores path, date, domain, validateTLSCertificates et al. req'd?
+                        // ignores path, date, domain, validateTLSCertificates et al. full details will be available in cookiestore if required
                         // name not blank, value not null
-                        if (cookieName.length() > 0)
+                        if (cookieName.length() > 0 && !cookies.containsKey(cookieName)) // if duplicates, only keep the first
                             cookie(cookieName, cookieVal);
                     }
                 }
@@ -1129,20 +1212,6 @@ public class HttpConnection implements Connection {
             w.close();
         }
 
-        private static String getRequestCookieString(Connection.Request req) {
-            StringBuilder sb = StringUtil.borrowBuilder();
-            boolean first = true;
-            for (Map.Entry<String, String> cookie : req.cookies().entrySet()) {
-                if (!first)
-                    sb.append("; ");
-                else
-                    first = false;
-                sb.append(cookie.getKey()).append('=').append(cookie.getValue());
-                // todo: spec says only ascii, no escaping / encoding defined. validate on set? or escape somehow here?
-            }
-            return StringUtil.releaseBuilder(sb);
-        }
-
         // for get url reqs, serialise the data map into the url
         private static void serialiseRequestUrl(Connection.Request req) throws IOException {
             URL in = req.url();
diff --git a/src/main/java/org/jsoup/internal/StringUtil.java b/src/main/java/org/jsoup/internal/StringUtil.java
index c6b8405f..f23c8723 100644
--- a/src/main/java/org/jsoup/internal/StringUtil.java
+++ b/src/main/java/org/jsoup/internal/StringUtil.java
@@ -2,6 +2,7 @@ package org.jsoup.internal;
 
 import org.jsoup.helper.Validate;
 
+import javax.annotation.Nullable;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.Arrays;
@@ -10,7 +11,8 @@ import java.util.Iterator;
 import java.util.Stack;
 
 /**
- * A minimal String utility class. Designed for internal jsoup use only.
+ A minimal String utility class. Designed for <b>internal</b> jsoup use only - the API and outcome may change without
+ notice.
  */
 public final class StringUtil {
     // memoised padding up to 21
@@ -25,7 +27,7 @@ public final class StringUtil {
      * @param sep string to place between strings
      * @return joined string
      */
-    public static String join(Collection strings, String sep) {
+    public static String join(Collection<?> strings, String sep) {
         return join(strings.iterator(), sep);
     }
 
@@ -35,7 +37,7 @@ public final class StringUtil {
      * @param sep string to place between strings
      * @return joined string
      */
-    public static String join(Iterator strings, String sep) {
+    public static String join(Iterator<?> strings, String sep) {
         if (!strings.hasNext())
             return "";
 
@@ -43,12 +45,12 @@ public final class StringUtil {
         if (!strings.hasNext()) // only one, avoid builder
             return start;
 
-        StringBuilder sb = StringUtil.borrowBuilder().append(start);
+        StringJoiner j = new StringJoiner(sep);
+        j.add(start);
         while (strings.hasNext()) {
-            sb.append(sep);
-            sb.append(strings.next());
+            j.add(strings.next());
         }
-        return StringUtil.releaseBuilder(sb);
+        return j.complete();
     }
 
     /**
@@ -61,6 +63,56 @@ public final class StringUtil {
         return join(Arrays.asList(strings), sep);
     }
 
+    /**
+     A StringJoiner allows incremental / filtered joining of a set of stringable objects.
+     @since 1.14.1
+     */
+    public static class StringJoiner {
+        @Nullable StringBuilder sb = borrowBuilder(); // sets null on builder release so can't accidentally be reused
+        final String separator;
+        boolean first = true;
+
+        /**
+         Create a new joiner, that uses the specified separator. MUST call {@link #complete()} or will leak a thread
+         local string builder.
+
+         @param separator the token to insert between strings
+         */
+        public StringJoiner(String separator) {
+            this.separator = separator;
+        }
+
+        /**
+         Add another item to the joiner, will be separated
+         */
+        public StringJoiner add(Object stringy) {
+            Validate.notNull(sb); // don't reuse
+            if (!first)
+                sb.append(separator);
+            sb.append(stringy);
+            first = false;
+            return this;
+        }
+
+        /**
+         Append content to the current item; not separated
+         */
+        public StringJoiner append(Object stringy) {
+            Validate.notNull(sb); // don't reuse
+            sb.append(stringy);
+            return this;
+        }
+
+        /**
+         Return the joined string, and release the builder back to the pool. This joiner cannot be reused.
+         */
+        public String complete() {
+            String string = releaseBuilder(sb);
+            sb = null;
+            return string;
+        }
+    }
+
     /**
      * Returns space padding (up to a max of 30).
      * @param width amount of padding desired
diff --git a/src/main/java/org/jsoup/nodes/Document.java b/src/main/java/org/jsoup/nodes/Document.java
index c9d2bf20..1e1217bb 100644
--- a/src/main/java/org/jsoup/nodes/Document.java
+++ b/src/main/java/org/jsoup/nodes/Document.java
@@ -1,5 +1,7 @@
 package org.jsoup.nodes;
 
+import org.jsoup.Connection;
+import org.jsoup.Jsoup;
 import org.jsoup.helper.DataUtil;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.StringUtil;
@@ -20,10 +22,11 @@ import java.util.List;
 
  @author Jonathan Hedley, jonathan@hedley.net */
 public class Document extends Element {
+    private @Nullable Connection connection; // the connection this doc was fetched from, if any
     private OutputSettings outputSettings = new OutputSettings();
     private Parser parser; // the parser used to parse this document
     private QuirksMode quirksMode = QuirksMode.noQuirks;
-    private String location;
+    private final String location;
     private boolean updateMetaCharset = false;
 
     /**
@@ -58,10 +61,24 @@ public class Document extends Element {
     /**
      * Get the URL this Document was parsed from. If the starting URL is a redirect,
      * this will return the final URL from which the document was served from.
+     * <p>Will return an empty string if the location is unknown (e.g. if parsed from a String).
      * @return location
      */
     public String location() {
-     return location;
+        return location;
+    }
+
+    /**
+     Returns the Connection (Request/Response) object that was used to fetch this document, if any; otherwise, a new
+     default Connection object. This can be used to continue a session, preserving settings and cookies, etc.
+     @return the Connection (session) associated with this Document, or an empty one otherwise.
+     @see Connection#newRequest()
+     */
+    public Connection connection() {
+        if (connection == null)
+            return Jsoup.newSession();
+        else
+            return connection;
     }
 
     /**
@@ -608,4 +625,19 @@ public class Document extends Element {
         this.parser = parser;
         return this;
     }
+
+    /**
+     Set the Connection used to fetch this document. This Connection is used as a session object when further requests are
+     made (e.g. when a form is submitted).
+
+     @param connection to set
+     @return this document, for chaining
+     @see Connection#newRequest()
+     @since 1.14.1
+     */
+    public Document connection(Connection connection) {
+        Validate.notNull(connection);
+        this.connection = connection;
+        return this;
+    }
 }
diff --git a/src/main/java/org/jsoup/nodes/FormElement.java b/src/main/java/org/jsoup/nodes/FormElement.java
index 261fc1e0..e3404af5 100644
--- a/src/main/java/org/jsoup/nodes/FormElement.java
+++ b/src/main/java/org/jsoup/nodes/FormElement.java
@@ -53,11 +53,14 @@ public class FormElement extends Element {
     }
 
     /**
-     * Prepare to submit this form. A Connection object is created with the request set up from the form values. You
-     * can then set up other options (like user-agent, timeout, cookies), then execute it.
-     * @return a connection prepared from the values of this form.
-     * @throws IllegalArgumentException if the form's absolute action URL cannot be determined. Make sure you pass the
-     * document's base URI when parsing.
+     Prepare to submit this form. A Connection object is created with the request set up from the form values. This
+     Connection will inherit the settings and the cookies (etc) of the connection/session used to request this Document
+     (if any), as available in {@link Document#connection()}
+     <p>You can then set up other options (like user-agent, timeout, cookies), then execute it.</p>
+
+     @return a connection prepared from the values of this form, in the same session as the one used to request it
+     @throws IllegalArgumentException if the form's absolute action URL cannot be determined. Make sure you pass the
+     document's base URI when parsing.
      */
     public Connection submit() {
         String action = hasAttr("action") ? absUrl("action") : baseUri();
@@ -65,7 +68,9 @@ public class FormElement extends Element {
         Connection.Method method = attr("method").equalsIgnoreCase("POST") ?
                 Connection.Method.POST : Connection.Method.GET;
 
-        return Jsoup.connect(action)
+        Document owner = ownerDocument();
+        Connection connection = owner != null? owner.connection().newRequest() : Jsoup.newSession();
+        return connection.url(action)
                 .data(formData())
                 .method(method);
     }
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
index 3009d08c..6d8c7142 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
@@ -62,6 +62,11 @@ public class HtmlTreeBuilder extends TreeBuilder {
         return ParseSettings.htmlDefault;
     }
 
+    @Override
+    HtmlTreeBuilder newInstance() {
+        return new HtmlTreeBuilder();
+    }
+
     @Override @ParametersAreNonnullByDefault
     protected void initialiseParse(Reader input, String baseUri, Parser parser) {
         super.initialiseParse(input, baseUri, parser);
diff --git a/src/main/java/org/jsoup/parser/ParseErrorList.java b/src/main/java/org/jsoup/parser/ParseErrorList.java
index b2ece0c5..a1c62277 100644
--- a/src/main/java/org/jsoup/parser/ParseErrorList.java
+++ b/src/main/java/org/jsoup/parser/ParseErrorList.java
@@ -9,12 +9,22 @@ import java.util.ArrayList;
  */
 public class ParseErrorList extends ArrayList<ParseError>{
     private static final int INITIAL_CAPACITY = 16;
+    private final int initialCapacity;
     private final int maxSize;
     
     ParseErrorList(int initialCapacity, int maxSize) {
         super(initialCapacity);
+        this.initialCapacity = initialCapacity;
         this.maxSize = maxSize;
     }
+
+    /**
+     Create a new ParseErrorList with the same settings, but no errors in the list
+     @param copy initial and max size details to copy
+     */
+    ParseErrorList(ParseErrorList copy) {
+        this(copy.initialCapacity, copy.maxSize);
+    }
     
     boolean canAddError() {
         return size() < maxSize;
diff --git a/src/main/java/org/jsoup/parser/ParseSettings.java b/src/main/java/org/jsoup/parser/ParseSettings.java
index 39485415..f94eecff 100644
--- a/src/main/java/org/jsoup/parser/ParseSettings.java
+++ b/src/main/java/org/jsoup/parser/ParseSettings.java
@@ -49,6 +49,10 @@ public class ParseSettings {
         preserveAttributeCase = attribute;
     }
 
+    ParseSettings(ParseSettings copy) {
+        this(copy.preserveTagCase, copy.preserveAttributeCase);
+    }
+
     /**
      * Normalizes a tag name according to the case preservation setting.
      */
diff --git a/src/main/java/org/jsoup/parser/Parser.java b/src/main/java/org/jsoup/parser/Parser.java
index 2c755384..8ff9667e 100644
--- a/src/main/java/org/jsoup/parser/Parser.java
+++ b/src/main/java/org/jsoup/parser/Parser.java
@@ -26,6 +26,20 @@ public class Parser {
         settings = treeBuilder.defaultSettings();
         errors = ParseErrorList.noTracking();
     }
+
+    /**
+     Creates a new Parser as a deep copy of this; including initializing a new TreeBuilder. Allows independent (multi-threaded) use.
+     @return a copied parser
+     */
+    public Parser newInstance() {
+        return new Parser(this);
+    }
+
+    private Parser(Parser copy) {
+        treeBuilder = copy.treeBuilder.newInstance(); // because extended
+        errors = new ParseErrorList(copy.errors); // only copies size, not contents
+        settings = new ParseSettings(copy.settings);
+    }
     
     public Document parseInput(String html, String baseUri) {
         return treeBuilder.parse(new StringReader(html), baseUri, this);
diff --git a/src/main/java/org/jsoup/parser/TreeBuilder.java b/src/main/java/org/jsoup/parser/TreeBuilder.java
index 197ce3b3..1f37f334 100644
--- a/src/main/java/org/jsoup/parser/TreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/TreeBuilder.java
@@ -59,6 +59,12 @@ abstract class TreeBuilder {
         return doc;
     }
 
+    /**
+     Create a new copy of this TreeBuilder
+     @return copy, ready for a new parse
+     */
+    abstract TreeBuilder newInstance();
+
     abstract List<Node> parseFragment(String inputFragment, Element context, String baseUri, Parser parser);
 
     protected void runParser() {
diff --git a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
index b85cc3f5..0b6d02d9 100644
--- a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
@@ -44,6 +44,11 @@ public class XmlTreeBuilder extends TreeBuilder {
         return parse(new StringReader(input), baseUri, new Parser(this));
     }
 
+    @Override
+    XmlTreeBuilder newInstance() {
+        return new XmlTreeBuilder();
+    }
+
     @Override
     protected boolean process(Token token) {
         // start tag, end tag, doctype, comment, character, eof


Test patch:
diff --git a/src/test/java/org/jsoup/helper/HttpConnectionTest.java b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
index a18c7543..3ed9cf9f 100644
--- a/src/test/java/org/jsoup/helper/HttpConnectionTest.java
+++ b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
@@ -261,7 +261,7 @@ public class HttpConnectionTest {
             con.execute();
         } catch (IllegalArgumentException e) {
             threw = true;
-            assertEquals("URL not yet set", e.getMessage());
+            assertEquals("URL not set. Make sure to call #url(...) before executing the request.", e.getMessage());
         }
         assertTrue(threw);
     }
diff --git a/src/test/java/org/jsoup/integration/ConnectTest.java b/src/test/java/org/jsoup/integration/ConnectTest.java
index f11eef55..28e79dac 100644
--- a/src/test/java/org/jsoup/integration/ConnectTest.java
+++ b/src/test/java/org/jsoup/integration/ConnectTest.java
@@ -96,7 +96,7 @@ public class ConnectTest {
             Document doc = con.get();
         } catch (HttpStatusException e) {
             threw = true;
-            assertEquals("org.jsoup.HttpStatusException: HTTP error fetching URL. Status=404, URL=" + e.getUrl(), e.toString());
+            assertEquals("org.jsoup.HttpStatusException: HTTP error fetching URL. Status=404, URL=[" + e.getUrl() + "]", e.toString());
             assertTrue(e.getUrl().startsWith(url));
             assertEquals(404, e.getStatusCode());
         } catch (IOException e) {
@@ -363,6 +363,22 @@ public class ConnectTest {
         assertEquals("token=asdfg123; uid=jhy", ihVal("Cookie", doc));
     }
 
+    @Test public void requestCookiesSurviveRedirect() throws IOException {
+        // this test makes sure that Request keyval cookies (not in the cookie store) are sent on subsequent redirections,
+        // when not using the session method
+        Connection con = Jsoup.connect(RedirectServlet.Url)
+            .data(RedirectServlet.LocationParam, echoUrl)
+            .cookie("LetMeIn", "True")
+            .cookie("DoesItWork", "Yes");
+
+        Connection.Response res = con.execute();
+        assertEquals(0, res.cookies().size()); // were not set by Redir or Echo servlet
+        Document doc = res.parse();
+        assertEquals(echoUrl, doc.location());
+        assertEquals("True", ihVal("Cookie: LetMeIn", doc));
+        assertEquals("Yes", ihVal("Cookie: DoesItWork", doc));
+    }
+
     @Test
     public void supportsDeflate() throws IOException {
         Connection.Response res = Jsoup.connect(Deflateservlet.Url).execute();
@@ -646,4 +662,13 @@ public class ConnectTest {
         assertEquals(actualDocText, largeRes.parse().text().length());
         assertEquals(actualDocText, unlimitedRes.parse().text().length());
     }
+
+    @Test public void repeatable() throws IOException {
+        String url = FileServlet.urlTo("/htmltests/large.html"); // 280 K
+        Connection con = Jsoup.connect(url).parser(Parser.xmlParser());
+        Document doc1 = con.get();
+        Document doc2 = con.get();
+        assertEquals("Large HTML", doc1.title());
+        assertEquals("Large HTML", doc2.title());
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/SessionIT.java b/src/test/java/org/jsoup/integration/SessionIT.java
new file mode 100644
index 00000000..29fff080
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/SessionIT.java
@@ -0,0 +1,130 @@
+package org.jsoup.integration;
+
+import org.jsoup.Connection;
+import org.jsoup.Jsoup;
+import org.jsoup.UncheckedIOException;
+import org.jsoup.integration.servlets.EchoServlet;
+import org.jsoup.integration.servlets.FileServlet;
+import org.jsoup.integration.servlets.SlowRider;
+import org.jsoup.nodes.Document;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Integration tests to test longer running Connection */
+public class SessionIT {
+    @BeforeAll
+    public static void setUp() {
+        TestServer.start();
+    }
+
+    @AfterAll
+    public static void tearDown() {
+        TestServer.stop();
+    }
+
+    @Test
+    public void multiThread() throws InterruptedException {
+        int numThreads = 20;
+        int numThreadLoops = 5;
+        String[] urls = {
+            FileServlet.urlTo("/htmltests/smh-biz-article-1.html.gz"),
+            FileServlet.urlTo("/htmltests/news-com-au-home.html.gz"),
+            FileServlet.urlTo("/htmltests/google-ipod.html.gz"),
+            FileServlet.urlTo("/htmltests/large.html"),
+        };
+        String[] titles = {
+            "The board’s next fear: the female quota",
+            "News.com.au | News from Australia and around the world online | NewsComAu",
+            "ipod - Google Search",
+            "Large HTML"
+        };
+        ThreadCatcher catcher = new ThreadCatcher();
+
+        Connection session = Jsoup.newSession();
+
+        Thread[] threads = new Thread[numThreads];
+        for (int threadNum = 0; threadNum < numThreads; threadNum++) {
+            Thread thread = new Thread(() -> {
+                for (int loop = 0; loop < numThreadLoops; loop++) {
+                    for (int i = 0; i < urls.length; i++) {
+                        try {
+                            Document doc = session.newRequest().url(urls[i]).get();
+                            assertEquals(titles[i], doc.title());
+                        } catch (IOException e) {
+                            throw new UncheckedIOException(e);
+                        }
+                    }
+                }
+            });
+            thread.setName("Runner-" + threadNum);
+            thread.start();
+            thread.setUncaughtExceptionHandler(catcher);
+            threads[threadNum] = thread;
+        }
+
+        // now join them all
+        for (Thread thread : threads) {
+            thread.join();
+        }
+
+        assertEquals(0, catcher.exceptionCount.get());
+    }
+
+    // test that we throw a nice clear exception if you try to multi-thread by forget .newRequest()
+    @Test
+    public void multiThreadWithoutNewRequestBlowsUp() throws InterruptedException {
+        int numThreads = 20;
+        String url = SlowRider.Url + "?" + SlowRider.MaxTimeParam + "=10000"; // this makes sure that the first req is still executing whilst the others run
+        String title = "Slow Rider";
+
+        ThreadCatcher catcher = new ThreadCatcher();
+        Connection session = Jsoup.newSession();
+
+        Thread[] threads = new Thread[numThreads];
+        for (int threadNum = 0; threadNum < numThreads; threadNum++) {
+            Thread thread = new Thread(() -> {
+                try {
+                    Document doc = session.url(url).get();
+                    assertEquals(title, doc.title());
+                } catch (IOException e) {
+                    throw new UncheckedIOException(e);
+                }
+            });
+            thread.setName("Runner-" + threadNum);
+            thread.start();
+            thread.setUncaughtExceptionHandler(catcher);
+            threads[threadNum] = thread;
+        }
+
+        // now join them all
+        for (Thread thread : threads) {
+            thread.join();
+        }
+
+        // only one should have passed, rest should have blown up (assuming the started whilst other was running)
+        assertEquals(numThreads - 1, catcher.multiThreadExceptions.get());
+        assertEquals(numThreads - 1, catcher.exceptionCount.get());
+    }
+
+
+    static class ThreadCatcher implements Thread.UncaughtExceptionHandler {
+        AtomicInteger exceptionCount = new AtomicInteger();
+        AtomicInteger multiThreadExceptions = new AtomicInteger();
+
+        @Override
+        public void uncaughtException(Thread t, Throwable e) {
+            if (e instanceof IllegalArgumentException && e.getMessage().contains("Multiple threads"))
+                multiThreadExceptions.incrementAndGet();
+            else
+                e.printStackTrace();
+            exceptionCount.incrementAndGet();
+        }
+    }
+
+}
diff --git a/src/test/java/org/jsoup/integration/SessionTest.java b/src/test/java/org/jsoup/integration/SessionTest.java
new file mode 100644
index 00000000..1de2e0ce
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/SessionTest.java
@@ -0,0 +1,140 @@
+package org.jsoup.integration;
+
+import org.jsoup.Connection;
+import org.jsoup.Jsoup;
+import org.jsoup.integration.servlets.CookieServlet;
+import org.jsoup.integration.servlets.EchoServlet;
+import org.jsoup.integration.servlets.FileServlet;
+import org.jsoup.nodes.Document;
+import org.jsoup.parser.Parser;
+import org.jsoup.select.Elements;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SessionTest {
+    @BeforeAll
+    public static void setUp() {
+        TestServer.start();
+    }
+
+    @AfterAll
+    public static void tearDown() {
+        TestServer.stop();
+    }
+
+    private static Elements keyEls(String key, Document doc) {
+        return doc.select("th:contains(" + key + ") + td");
+    }
+
+    private static String keyText(String key, Document doc) {
+        return doc.selectFirst("th:contains(" + key + ") + td").text();
+    }
+
+    @Test
+    public void testPathScopedCookies() throws IOException {
+        final Connection session = Jsoup.newSession();
+        final String userAgent = "Jsoup Testalot v0.1";
+
+        session.userAgent(userAgent);
+        session.url(CookieServlet.Url);
+
+        // should have no cookies:
+        Connection con1 = session.newRequest();
+        Document doc1 = con1.get();
+        assertEquals(0, doc1.select("table tr").size()); // none sent to servlet
+
+        // set the cookies
+        Connection con2 = session.newRequest().data(CookieServlet.SetCookiesParam, "1");
+        Document doc2 = con2.get();
+        assertEquals(0, doc2.select("table tr").size());  // none sent to servlet - we just got them!
+        Map<String, String> cookies = con2.response().cookies(); // simple cookie response, all named "One", so should be first sent
+        assertEquals(1, cookies.size());
+        assertEquals("Root", cookies.get("One"));
+
+        // todo - interrogate cookie-store
+
+        // check that they are sent and filtered to the right path
+        Connection con3 = session.newRequest();
+        Document doc3 = con3.get();
+        assertCookieServlet(doc3);
+
+        Document echo = session.newRequest().url(EchoServlet.Url).get();
+        assertEchoServlet(echo);
+        assertEquals(userAgent, keyText("User-Agent", echo)); // check that customer user agent sent on session arrived
+
+        // check that cookies aren't set out of the session
+        Document doc4 = Jsoup.newSession().url(CookieServlet.Url).get();
+        assertEquals(0, doc4.select("table tr").size()); // none sent to servlet
+
+        // check can add local ones also
+        Document doc5 = session.newRequest().cookie("Bar", "Qux").get();
+        Elements doc5Bar = keyEls("Bar", doc5);
+        assertEquals("Qux", doc5Bar.first().text());
+    }
+
+    // validate that only cookies set by cookie servlet get to the cookie servlet path
+    private void assertCookieServlet(Document doc) {
+        assertEquals(2, doc.select("table tr").size());  // two of three sent to servlet (/ and /CookieServlet)
+        Elements doc3Els = keyEls("One", doc);
+        assertEquals(2, doc3Els.size());
+        assertEquals("CookieServlet", doc3Els.get(0).text()); // ordered by most specific path
+        assertEquals("Root", doc3Els.get(1).text()); // ordered by most specific path
+    }
+
+    // validate that only for echo servlet
+    private void assertEchoServlet(Document doc) {
+        Elements echoEls = keyEls("Cookie: One", doc);  // two of three sent to servlet (/ and /EchoServlet)
+        assertEquals(2, echoEls.size());
+        assertEquals("EchoServlet", echoEls.get(0).text()); // ordered by most specific path - /Echo
+        assertEquals("Root", echoEls.get(1).text()); // ordered by most specific path - /
+    }
+
+    @Test
+    public void testPathScopedCookiesOnRedirect() throws IOException {
+        Connection session = Jsoup.newSession();
+
+        Document doc1 = session.newRequest()
+            .url(CookieServlet.Url)
+            .data(CookieServlet.LocationParam, EchoServlet.Url)
+            .data(CookieServlet.SetCookiesParam, "1")
+            .get();
+
+        // we should be redirected to the echo servlet with cookies
+        assertEquals(EchoServlet.Url, doc1.location());
+        assertEchoServlet(doc1); // checks we only have /echo cookies
+
+        Document doc2 = session.newRequest()
+            .url(EchoServlet.Url)
+            .get();
+        assertEchoServlet(doc2); // test retained in session
+
+        Document doc3 = session.newRequest()
+            .url(CookieServlet.Url)
+            .get();
+        assertCookieServlet(doc3); // and so were the /cookie cookies
+    }
+
+    @Test
+    public void testCanChangeParsers() throws IOException {
+        Connection session = Jsoup.newSession().parser(Parser.xmlParser());
+
+        String xmlUrl = FileServlet.urlTo("/htmltests/xml-test.xml");
+        String xmlVal = "<doc><val>One<val>Two</val>Three</val></doc>\n";
+
+        Document doc1 = session.newRequest().url(xmlUrl).get();
+        assertEquals(xmlVal, doc1.html()); // not HTML normed, used XML parser
+
+        Document doc2 = session.newRequest().parser(Parser.htmlParser()).url(xmlUrl).get();
+        assertTrue(doc2.html().startsWith("<html>"));
+
+        Document doc3 = session.newRequest().url(xmlUrl).get();
+        assertEquals(xmlVal, doc3.html()); // did not blow away xml default
+    }
+}
diff --git a/src/test/java/org/jsoup/integration/TestServer.java b/src/test/java/org/jsoup/integration/TestServer.java
index a4fb2b96..6af7c21a 100644
--- a/src/test/java/org/jsoup/integration/TestServer.java
+++ b/src/test/java/org/jsoup/integration/TestServer.java
@@ -34,7 +34,7 @@ public class TestServer {
 
     public static void stop() {
         synchronized (jetty) {
-            int count = latch.decrementAndGet();
+            int count = latch.getAndDecrement();
             if (count == 0) {
                 try {
                     jetty.stop();
diff --git a/src/test/java/org/jsoup/integration/servlets/CookieServlet.java b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
new file mode 100644
index 00000000..2249f971
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
@@ -0,0 +1,76 @@
+package org.jsoup.integration.servlets;
+
+import org.jsoup.integration.TestServer;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+public class CookieServlet extends BaseServlet{
+    public static final String Url = TestServer.map(CookieServlet.class);
+    public static final String SetCookiesParam = "setCookies";
+    public static final String LocationParam = "loc";
+
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
+        doIt(req, res);
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
+        doIt(req, res);
+    }
+
+    @Override
+    protected void doPut(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
+        doIt(req, res);
+    }
+
+    private void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
+        // Do we want to set cookies?
+        if (req.getParameter(SetCookiesParam) != null)
+            setCookies(res);
+
+        // Do we want to redirect elsewhere?
+        String loc = req.getParameter(LocationParam);
+        if (loc != null) {
+            res.sendRedirect(loc);
+            return;
+        }
+
+        // print out the cookies that were received
+        res.setContentType(TextHtml);
+        res.setStatus(200);
+
+        PrintWriter w = res.getWriter();
+        w.println("<table>");
+        final Cookie[] cookies = req.getCookies();
+        if (cookies != null) {
+            for (Cookie cookie : cookies) {
+                EchoServlet.write(w, cookie.getName(), cookie.getValue());
+            }
+        }
+        w.println("</table>");
+    }
+
+    private void setCookies(HttpServletResponse res) {
+        Cookie one = new Cookie("One", "Root");
+        one.setPath("/");
+        res.addCookie(one);
+
+        Cookie two = new Cookie("One", "CookieServlet");
+        two.setPath("/CookieServlet");
+        two.setHttpOnly(true);
+        two.setComment("Quite nice");
+        res.addCookie(two);
+
+        Cookie three = new Cookie("One", "EchoServlet");
+        three.setPath("/EchoServlet");
+        res.addCookie(three);
+    }
+
+}
diff --git a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
index defb6e60..9931f978 100644
--- a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
@@ -7,6 +7,7 @@ import org.jsoup.integration.TestServer;
 
 import javax.servlet.MultipartConfigElement;
 import javax.servlet.ServletException;
+import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.Part;
@@ -78,6 +79,14 @@ public class EchoServlet extends BaseServlet {
             }
         }
 
+        // cookies
+        final Cookie[] cookies = req.getCookies();
+        if (cookies != null) {
+            for (Cookie cookie : cookies) {
+                EchoServlet.write(w, "Cookie: " + cookie.getName(), cookie.getValue());
+            }
+        }
+
         // the request params
         Enumeration<String> parameterNames = req.getParameterNames();
         while (parameterNames.hasMoreElements()) {
@@ -111,7 +120,7 @@ public class EchoServlet extends BaseServlet {
         w.println("</table>");
     }
 
-    private static void write(PrintWriter w, String key, String val) {
+    static void write(PrintWriter w, String key, String val) {
         w.println("<tr><th>" + escape(key) + "</th><td>" + escape(val) + "</td></tr>");
     }
 
diff --git a/src/test/java/org/jsoup/integration/servlets/FileServlet.java b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
index 06ed2149..7447e8c7 100644
--- a/src/test/java/org/jsoup/integration/servlets/FileServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
@@ -25,6 +25,8 @@ public class FileServlet extends BaseServlet {
         File file = ParseTest.getFile(location);
         if (file.exists()) {
             res.setContentType(contentType);
+            if (file.getName().endsWith("gz"))
+                res.addHeader("Content-Encoding", "gzip");
             res.setStatus(HttpServletResponse.SC_OK);
 
             ServletOutputStream out = res.getOutputStream();
diff --git a/src/test/java/org/jsoup/integration/servlets/SlowRider.java b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
index bccd8a97..e8db4600 100644
--- a/src/test/java/org/jsoup/integration/servlets/SlowRider.java
+++ b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
@@ -29,6 +29,7 @@ public class SlowRider extends BaseServlet {
         }
 
         long startTime = System.currentTimeMillis();
+        w.println("<title>Slow Rider</title>");
         while (true) {
             w.println("<p>Are you still there?");
             boolean err = w.checkError(); // flush, and check still ok
diff --git a/src/test/java/org/jsoup/nodes/DocumentTest.java b/src/test/java/org/jsoup/nodes/DocumentTest.java
index 97d48385..cdd26234 100644
--- a/src/test/java/org/jsoup/nodes/DocumentTest.java
+++ b/src/test/java/org/jsoup/nodes/DocumentTest.java
@@ -140,6 +140,11 @@ public class DocumentTest {
         assertEquals("http://www.nytimes.com/2010/07/26/business/global/26bp.html?hp",baseUri);
     }
 
+    @Test public void testLocationFromString() {
+        Document doc = Jsoup.parse("<p>Hello");
+        assertEquals("", doc.location());
+    }
+
     @Test public void testHtmlAndXmlSyntax() {
         String h = "<!DOCTYPE html><body><img async checked='checked' src='&<>\"'>&lt;&gt;&amp;&quot;<foo />bar";
         Document doc = Jsoup.parse(h);
diff --git a/src/test/java/org/jsoup/nodes/FormElementTest.java b/src/test/java/org/jsoup/nodes/FormElementTest.java
index a931efaf..c64cbbfd 100644
--- a/src/test/java/org/jsoup/nodes/FormElementTest.java
+++ b/src/test/java/org/jsoup/nodes/FormElementTest.java
@@ -2,8 +2,16 @@ package org.jsoup.nodes;
 
 import org.jsoup.Connection;
 import org.jsoup.Jsoup;
+import org.jsoup.integration.TestServer;
+import org.jsoup.integration.servlets.CookieServlet;
+import org.jsoup.integration.servlets.EchoServlet;
+import org.jsoup.integration.servlets.FileServlet;
+import org.jsoup.select.Elements;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import java.io.IOException;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -14,6 +22,16 @@ import static org.junit.jupiter.api.Assertions.*;
  * @author Jonathan Hedley
  */
 public class FormElementTest {
+    @BeforeAll
+    public static void setUp() {
+        TestServer.start();
+    }
+
+    @AfterAll
+    public static void tearDown() {
+        TestServer.stop();
+    }
+
     @Test public void hasAssociatedControls() {
         //"button", "fieldset", "input", "keygen", "object", "output", "select", "textarea"
         String html = "<form id=1><button id=1><fieldset id=2 /><input id=3><keygen id=4><object id=5><output id=6>" +
@@ -175,4 +193,27 @@ public class FormElementTest {
         assertEquals("login", data.get(1).key());
         assertNull(doc.selectFirst("input[name=pass]"));
     }
+
+    @Test public void formSubmissionCarriesCookiesFromSession() throws IOException {
+        String echoUrl = EchoServlet.Url; // this is a dirty hack to initialize the EchoServlet(!)
+        Document cookieDoc = Jsoup.connect(CookieServlet.Url)
+            .data(CookieServlet.SetCookiesParam, "1")
+            .get();
+        Document formDoc = cookieDoc.connection().newRequest() // carries cookies from above set
+            .url(FileServlet.urlTo("/htmltests/upload-form.html"))
+            .get();
+        FormElement form = formDoc.select("form").forms().get(0);
+        Document echo = form.submit().post();
+
+        assertEquals(echoUrl, echo.location());
+        Elements els = echo.select("th:contains(Cookie: One)");
+        // ensure that the cookies are there and in path-specific order (two with same name)
+        assertEquals("EchoServlet", els.get(0).nextElementSibling().text());
+        assertEquals("Root", els.get(1).nextElementSibling().text());
+
+        // make sure that the session following kept unique requests
+        assertTrue(cookieDoc.connection().response().url().toExternalForm().contains("CookieServlet"));
+        assertTrue(formDoc.connection().response().url().toExternalForm().contains("upload-form"));
+        assertTrue(echo.connection().response().url().toExternalForm().contains("EchoServlet"));
+    }
 }