Author: remm Date: Thu Dec 11 20:10:47 2014 New Revision: 1644747 URL: http://svn.apache.org/r1644747 Log: - Rebase on websocket from trunk. - Correctly implement headers case insensitivity. - Allow optional use of user extensions. - Allow using partial binary message handlers. - Limit ping/pong message size. - Allow configuration of the time interval for the periodic event. - More accurate annotations processing. - Allow optional default for origin header in the client.
Added: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/CaseInsensitiveKeyMap.java Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Constants.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/LocalStrings.properties tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/TransformationFactory.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Util.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsHandshakeResponse.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsRemoteEndpointImplBase.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsWebSocketContainer.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMessageHandlerBase.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMethodMapping.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/UpgradeUtil.java tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/WsHandshakeRequest.java tomcat/tc8.0.x/trunk/webapps/docs/changelog.xml tomcat/tc8.0.x/trunk/webapps/docs/config/systemprops.xml Added: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/CaseInsensitiveKeyMap.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/CaseInsensitiveKeyMap.java?rev=1644747&view=auto ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/CaseInsensitiveKeyMap.java (added) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/CaseInsensitiveKeyMap.java Thu Dec 11 20:10:47 2014 @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomcat.websocket; + +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.tomcat.util.res.StringManager; + +/** + * A Map implementation that uses case-insensitive (using {@link + * Locale#ENGLISH}) strings as keys. + * <p> + * Keys must be instances of {@link String}. Note that this means that + * <code>null</code> keys are not permitted. + * <p> + * This implementation is not thread-safe. + * + * @param <V> Type of values placed in this Map. + */ +public class CaseInsensitiveKeyMap<V> extends AbstractMap<String,V> { + + private static final StringManager sm = + StringManager.getManager(Constants.PACKAGE_NAME); + + private final Map<Key,V> map = new HashMap<>(); + + + @Override + public V get(Object key) { + return map.get(Key.getInstance(key)); + } + + + @Override + public V put(String key, V value) { + Key caseInsensitiveKey = Key.getInstance(key); + if (caseInsensitiveKey == null) { + throw new NullPointerException(sm.getString("caseInsensitiveKeyMap.nullKey")); + } + return map.put(caseInsensitiveKey, value); + } + + + /** + * {@inheritDoc} + * <p> + * <b>Use this method with caution</b>. If the input Map contains duplicate + * keys when the keys are compared in a case insensitive manner then some + * values will be lost when inserting via this method. + */ + @Override + public void putAll(Map<? extends String, ? extends V> m) { + super.putAll(m); + } + + + @Override + public boolean containsKey(Object key) { + return map.containsKey(Key.getInstance(key)); + } + + + @Override + public V remove(Object key) { + return map.remove(Key.getInstance(key)); + } + + + @Override + public Set<Entry<String, V>> entrySet() { + return new EntrySet<>(map.entrySet()); + } + + + private static class EntrySet<V> extends AbstractSet<Entry<String,V>> { + + private final Set<Entry<Key,V>> entrySet; + + public EntrySet(Set<Map.Entry<Key,V>> entrySet) { + this.entrySet = entrySet; + } + + @Override + public Iterator<Entry<String,V>> iterator() { + return new EntryIterator<>(entrySet.iterator()); + } + + @Override + public int size() { + return entrySet.size(); + } + } + + + private static class EntryIterator<V> implements Iterator<Entry<String,V>> { + + private final Iterator<Entry<Key,V>> iterator; + + public EntryIterator(Iterator<Entry<Key,V>> iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Entry<String,V> next() { + Entry<Key,V> entry = iterator.next(); + return new EntryImpl<>(entry.getKey().getKey(), entry.getValue()); + } + + @Override + public void remove() { + iterator.remove(); + } + } + + + private static class EntryImpl<V> implements Entry<String,V> { + + private final String key; + private final V value; + + public EntryImpl(String key, V value) { + this.key = key; + this.value = value; + } + + @Override + public String getKey() { + return key; + } + + @Override + public V getValue() { + return value; + } + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } + } + + private static class Key { + + private final String key; + private final String lcKey; + + private Key(String key) { + this.key = key; + this.lcKey = key.toLowerCase(Locale.ENGLISH); + } + + public String getKey() { + return key; + } + + @Override + public int hashCode() { + return lcKey.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Key other = (Key) obj; + return lcKey.equals(other.lcKey); + } + + public static Key getInstance(Object o) { + if (o instanceof String) { + return new Key((String) o); + } + return null; + } + } +} Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Constants.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Constants.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Constants.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Constants.java Thu Dec 11 20:10:47 2014 @@ -19,7 +19,6 @@ package org.apache.tomcat.websocket; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Locale; import javax.websocket.Extension; @@ -44,12 +43,15 @@ public class Constants { static final byte INTERNAL_OPCODE_FLUSH = 0x18; // Buffers - static final int DEFAULT_BUFFER_SIZE = 8 * 1024; + static final int DEFAULT_BUFFER_SIZE = Integer.getInteger( + "org.apache.tomcat.websocket.DEFAULT_BUFFER_SIZE", 8 * 1024) + .intValue(); // Client connection public static final String HOST_HEADER_NAME = "Host"; public static final String UPGRADE_HEADER_NAME = "Upgrade"; public static final String UPGRADE_HEADER_VALUE = "websocket"; + public static final String ORIGIN_HEADER_NAME = "Origin"; public static final String CONNECTION_HEADER_NAME = "Connection"; public static final String CONNECTION_HEADER_VALUE = "upgrade"; public static final String WS_VERSION_HEADER_NAME = "Sec-WebSocket-Version"; @@ -57,12 +59,32 @@ public class Constants { public static final String WS_KEY_HEADER_NAME = "Sec-WebSocket-Key"; public static final String WS_PROTOCOL_HEADER_NAME = "Sec-WebSocket-Protocol"; - public static final String WS_PROTOCOL_HEADER_NAME_LOWER = - WS_PROTOCOL_HEADER_NAME.toLowerCase(Locale.ENGLISH); public static final String WS_EXTENSIONS_HEADER_NAME = "Sec-WebSocket-Extensions"; - public static final String WS_EXTENSIONS_HEADER_NAME_LOWER = - WS_EXTENSIONS_HEADER_NAME.toLowerCase(Locale.ENGLISH); + + // Configuration for Origin header in client + static final String DEFAULT_ORIGIN_HEADER_VALUE = + System.getProperty("org.apache.tomcat.websocket.DEFAULT_ORIGIN_HEADER_VALUE"); + + // Configuration for background processing checks intervals + static final int DEFAULT_PROCESS_PERIOD = Integer.getInteger( + "org.apache.tomcat.websocket.DEFAULT_PROCESS_PERIOD", 10) + .intValue(); + + /* Configuration for extensions + * Note: These options are primarily present to enable this implementation + * to pass compliance tests. They are expected to be removed once + * the WebSocket API includes a mechanism for adding custom extensions + * and disabling built-in extensions. + */ + static final boolean DISABLE_BUILTIN_EXTENSIONS = + Boolean.getBoolean("org.apache.tomcat.websocket.DISABLE_BUILTIN_EXTENSIONS"); + static final boolean ALLOW_UNSUPPORTED_EXTENSIONS = + Boolean.getBoolean("org.apache.tomcat.websocket.ALLOW_UNSUPPORTED_EXTENSIONS"); + + // Configuration for stream behavior + static final boolean STREAMS_DROP_EMPTY_MESSAGES = + Boolean.getBoolean("org.apache.tomcat.websocket.STREAMS_DROP_EMPTY_MESSAGES"); public static final boolean STRICT_SPEC_COMPLIANCE = Boolean.getBoolean( @@ -71,9 +93,13 @@ public class Constants { public static final List<Extension> INSTALLED_EXTENSIONS; static { - List<Extension> installed = new ArrayList<>(1); - installed.add(new WsExtension("permessage-deflate")); - INSTALLED_EXTENSIONS = Collections.unmodifiableList(installed); + if (DISABLE_BUILTIN_EXTENSIONS) { + INSTALLED_EXTENSIONS = Collections.unmodifiableList(new ArrayList<Extension>()); + } else { + List<Extension> installed = new ArrayList<>(1); + installed.add(new WsExtension("permessage-deflate")); + INSTALLED_EXTENSIONS = Collections.unmodifiableList(installed); + } } private Constants() { Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/LocalStrings.properties URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/LocalStrings.properties?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/LocalStrings.properties (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/LocalStrings.properties Thu Dec 11 20:10:47 2014 @@ -28,6 +28,8 @@ asyncChannelWrapperSecure.wrongStateWrit backgroundProcessManager.processFailed=A background process failed +caseInsensitiveKeyMap.nullKey=Null keys are not permitted + perMessageDeflate.deflateFailed=Failed to decompress a compressed WebSocket frame perMessageDeflate.duplicateParameter=Duplicate definition of the [{0}] extension parameter perMessageDeflate.invalidWindowSize=An invalid windows of [{1}] size was specified for [{0}]. Valid values are whole numbers from 8 to 15 inclusive. @@ -76,6 +78,7 @@ wsRemoteEndpoint.noEncoder=No encoder sp wsRemoteEndpoint.wrongState=The remote endpoint was in state [{0}] which is an invalid state for called method wsRemoteEndpoint.nullData=Invalid null data argument wsRemoteEndpoint.nullHandler=Invalid null handler argument +wsRemoteEndpoint.tooMuchData=Ping or pong may not send more than 125 bytes # Note the following message is used as a close reason in a WebSocket control # frame and therefore must be 123 bytes (not characters) or less in length. Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/TransformationFactory.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/TransformationFactory.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/TransformationFactory.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/TransformationFactory.java Thu Dec 11 20:10:47 2014 @@ -41,7 +41,11 @@ public class TransformationFactory { if (PerMessageDeflate.NAME.equals(name)) { return PerMessageDeflate.negotiate(preferences, isServer); } - throw new IllegalArgumentException( - sm.getString("transformerFactory.unsupportedExtension", name)); + if (Constants.ALLOW_UNSUPPORTED_EXTENSIONS) { + return null; + } else { + throw new IllegalArgumentException( + sm.getString("transformerFactory.unsupportedExtension", name)); + } } } Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Util.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Util.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Util.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/Util.java Thu Dec 11 20:10:47 2014 @@ -49,6 +49,7 @@ import javax.websocket.PongMessage; import javax.websocket.Session; import org.apache.tomcat.util.res.StringManager; +import org.apache.tomcat.websocket.pojo.PojoMessageHandlerPartialBinary; import org.apache.tomcat.websocket.pojo.PojoMessageHandlerWholeBinary; import org.apache.tomcat.websocket.pojo.PojoMessageHandlerWholeText; @@ -307,8 +308,7 @@ public class Util { return Boolean.valueOf(value); } else if (type.equals(byte.class) || type.equals(Byte.class)) { return Byte.valueOf(value); - } else if (value.length() == 1 && - (type.equals(char.class) || type.equals(Character.class))) { + } else if (type.equals(char.class) || type.equals(Character.class)) { return Character.valueOf(value.charAt(0)); } else if (type.equals(double.class) || type.equals(Double.class)) { return Double.valueOf(value); @@ -379,45 +379,40 @@ public class Util { new MessageHandlerResult(listener, MessageHandlerResultType.PONG); results.add(result); - // Relatively simple cases - handler needs wrapping but no decoder to - // convert it to one of the types expected by the frame handling code + // Handler needs wrapping and optional decoder to convert it to one of + // the types expected by the frame handling code } else if (byte[].class.isAssignableFrom(target)) { + boolean whole = MessageHandler.Whole.class.isAssignableFrom(listener.getClass()); MessageHandlerResult result = new MessageHandlerResult( - new PojoMessageHandlerWholeBinary(listener, - getOnMessageMethod(listener), session, - endpointConfig, null, new Object[1], 0, true, -1, - false, -1), + whole ? new PojoMessageHandlerWholeBinary(listener, + getOnMessageMethod(listener), session, + endpointConfig, matchDecoders(target, endpointConfig, true), + new Object[1], 0, true, -1, false, -1) : + new PojoMessageHandlerPartialBinary(listener, + getOnMessagePartialMethod(listener), session, + new Object[2], 0, true, 1, -1, -1), MessageHandlerResultType.BINARY); results.add(result); } else if (InputStream.class.isAssignableFrom(target)) { MessageHandlerResult result = new MessageHandlerResult( new PojoMessageHandlerWholeBinary(listener, getOnMessageMethod(listener), session, - endpointConfig, null, new Object[1], 0, true, -1, - true, -1), + endpointConfig, matchDecoders(target, endpointConfig, true), + new Object[1], 0, true, -1, true, -1), MessageHandlerResultType.BINARY); results.add(result); } else if (Reader.class.isAssignableFrom(target)) { MessageHandlerResult result = new MessageHandlerResult( new PojoMessageHandlerWholeText(listener, getOnMessageMethod(listener), session, - endpointConfig, null, new Object[1], 0, true, -1, - -1), + endpointConfig, matchDecoders(target, endpointConfig, false), + new Object[1], 0, true, -1, -1), MessageHandlerResultType.TEXT); results.add(result); } else { - // More complex case - listener that requires a decoder - DecoderMatch decoderMatch; - try { - List<Class<? extends Decoder>> decoders = - endpointConfig.getDecoders(); - @SuppressWarnings("unchecked") - List<DecoderEntry> decoderEntries = getDecoders( - decoders.toArray(new Class[decoders.size()])); - decoderMatch = new DecoderMatch(target, decoderEntries); - } catch (DeploymentException e) { - throw new IllegalArgumentException(e); - } + // Handler needs wrapping and requires decoder to convert it to one + // of the types expected by the frame handling code + DecoderMatch decoderMatch = matchDecoders(target, endpointConfig); Method m = getOnMessageMethod(listener); if (decoderMatch.getBinaryDecoders().size() > 0) { MessageHandlerResult result = new MessageHandlerResult( @@ -425,7 +420,7 @@ public class Util { endpointConfig, decoderMatch.getBinaryDecoders(), new Object[1], 0, false, -1, false, -1), - MessageHandlerResultType.BINARY); + MessageHandlerResultType.BINARY); results.add(result); } if (decoderMatch.getTextDecoders().size() > 0) { @@ -434,7 +429,7 @@ public class Util { endpointConfig, decoderMatch.getTextDecoders(), new Object[1], 0, false, -1, -1), - MessageHandlerResultType.TEXT); + MessageHandlerResultType.TEXT); results.add(result); } } @@ -447,6 +442,34 @@ public class Util { return results; } + private static List<Class<? extends Decoder>> matchDecoders(Class<?> target, + EndpointConfig endpointConfig, boolean binary) { + DecoderMatch decoderMatch = matchDecoders(target, endpointConfig); + if (binary) { + if (decoderMatch.getBinaryDecoders().size() > 0) { + return decoderMatch.getBinaryDecoders(); + } + } else if (decoderMatch.getTextDecoders().size() > 0) { + return decoderMatch.getTextDecoders(); + } + return null; + } + + private static DecoderMatch matchDecoders(Class<?> target, + EndpointConfig endpointConfig) { + DecoderMatch decoderMatch; + try { + List<Class<? extends Decoder>> decoders = + endpointConfig.getDecoders(); + @SuppressWarnings("unchecked") + List<DecoderEntry> decoderEntries = getDecoders( + decoders.toArray(new Class[decoders.size()])); + decoderMatch = new DecoderMatch(target, decoderEntries); + } catch (DeploymentException e) { + throw new IllegalArgumentException(e); + } + return decoderMatch; + } public static void parseExtensionHeader(List<Extension> extensions, String header) { @@ -536,6 +559,15 @@ public class Util { } } + private static Method getOnMessagePartialMethod(MessageHandler listener) { + try { + return listener.getClass().getMethod("onMessage", Object.class, Boolean.TYPE); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalArgumentException( + sm.getString("util.invalidMessageHandler"), e); + } + } + public static class DecoderMatch { @@ -543,9 +575,10 @@ public class Util { new ArrayList<>(); private final List<Class<? extends Decoder>> binaryDecoders = new ArrayList<>(); - + private final Class<?> target; public DecoderMatch(Class<?> target, List<DecoderEntry> decoderEntries) { + this.target = target; for (DecoderEntry decoderEntry : decoderEntries) { if (decoderEntry.getClazz().isAssignableFrom(target)) { if (Binary.class.isAssignableFrom( @@ -591,6 +624,11 @@ public class Util { } + public Class<?> getTarget() { + return target; + } + + public boolean hasMatches() { return (textDecoders.size() > 0) || (binaryDecoders.size() > 0); } Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsHandshakeResponse.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsHandshakeResponse.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsHandshakeResponse.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsHandshakeResponse.java Thu Dec 11 20:10:47 2014 @@ -16,9 +16,10 @@ */ package org.apache.tomcat.websocket; -import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import javax.websocket.HandshakeResponse; @@ -27,16 +28,22 @@ import javax.websocket.HandshakeResponse */ public class WsHandshakeResponse implements HandshakeResponse { - private final Map<String,List<String>> headers; + private final Map<String,List<String>> headers = new CaseInsensitiveKeyMap<>(); public WsHandshakeResponse() { - this(new HashMap<String,List<String>>()); } public WsHandshakeResponse(Map<String,List<String>> headers) { - this.headers = headers; + for (Entry<String,List<String>> entry : headers.entrySet()) { + if (this.headers.containsKey(entry.getKey())) { + this.headers.get(entry.getKey()).addAll(entry.getValue()); + } else { + List<String> values = new ArrayList<>(entry.getValue()); + this.headers.put(entry.getKey(), values); + } + } } Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsRemoteEndpointImplBase.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsRemoteEndpointImplBase.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsRemoteEndpointImplBase.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsRemoteEndpointImplBase.java Thu Dec 11 20:10:47 2014 @@ -168,6 +168,9 @@ public abstract class WsRemoteEndpointIm @Override public void sendPing(ByteBuffer applicationData) throws IOException, IllegalArgumentException { + if (applicationData.remaining() > 125) { + throw new IllegalArgumentException(sm.getString("wsRemoteEndpoint.tooMuchData")); + } startMessageBlock(Constants.OPCODE_PING, applicationData, true); } @@ -175,6 +178,9 @@ public abstract class WsRemoteEndpointIm @Override public void sendPong(ByteBuffer applicationData) throws IOException, IllegalArgumentException { + if (applicationData.remaining() > 125) { + throw new IllegalArgumentException(sm.getString("wsRemoteEndpoint.tooMuchData")); + } startMessageBlock(Constants.OPCODE_PONG, applicationData, true); } @@ -544,13 +550,22 @@ public abstract class WsRemoteEndpointIm throw new IllegalArgumentException(sm.getString("wsRemoteEndpoint.nullHandler")); } - if (Util.isPrimitive(obj.getClass())) { + /* + * Note that the implementation will convert primitives and their object + * equivalents by default but that users are free to specify their own + * encoders and decoders for this if they wish. + */ + Encoder encoder = findEncoder(obj); + if (encoder == null && Util.isPrimitive(obj.getClass())) { String msg = obj.toString(); sendStringByCompletion(msg, completion); return; } - - Encoder encoder = findEncoder(obj); + if (encoder == null && byte[].class.isAssignableFrom(obj.getClass())) { + ByteBuffer msg = ByteBuffer.wrap((byte[]) obj); + sendBytesByCompletion(msg, completion); + return; + } try { if (encoder instanceof Encoder.Text) { @@ -573,7 +588,7 @@ public abstract class WsRemoteEndpointIm throw new EncodeException(obj, sm.getString( "wsRemoteEndpoint.noEncoder", obj.getClass())); } - } catch (EncodeException | IOException e) { + } catch (Exception e) { SendResult sr = new SendResult(e); completion.onResult(sr); } @@ -860,12 +875,13 @@ public abstract class WsRemoteEndpointIm } - private class WsOutputStream extends OutputStream { + private static class WsOutputStream extends OutputStream { private final WsRemoteEndpointImplBase endpoint; private final ByteBuffer buffer = ByteBuffer.allocate(Constants.DEFAULT_BUFFER_SIZE); private final Object closeLock = new Object(); private volatile boolean closed = false; + private volatile boolean used = false; public WsOutputStream(WsRemoteEndpointImplBase endpoint) { this.endpoint = endpoint; @@ -898,6 +914,7 @@ public abstract class WsRemoteEndpointIm throw new IndexOutOfBoundsException(); } + used = true; if (buffer.remaining() == 0) { flush(); } @@ -920,7 +937,11 @@ public abstract class WsRemoteEndpointIm sm.getString("wsRemoteEndpoint.closedOutputStream")); } - doWrite(false); + // Optimisation. If there is no data to flush then do not send an + // empty message. + if (!Constants.STREAMS_DROP_EMPTY_MESSAGES || buffer.position() > 0) { + doWrite(false); + } } @Override @@ -936,9 +957,11 @@ public abstract class WsRemoteEndpointIm } private void doWrite(boolean last) throws IOException { - buffer.flip(); - endpoint.startMessageBlock(Constants.OPCODE_BINARY, buffer, last); - stateMachine.complete(last); + if (!Constants.STREAMS_DROP_EMPTY_MESSAGES || used) { + buffer.flip(); + endpoint.startMessageBlock(Constants.OPCODE_BINARY, buffer, last); + } + endpoint.stateMachine.complete(last); buffer.clear(); } } @@ -950,6 +973,7 @@ public abstract class WsRemoteEndpointIm private final CharBuffer buffer = CharBuffer.allocate(Constants.DEFAULT_BUFFER_SIZE); private final Object closeLock = new Object(); private volatile boolean closed = false; + private volatile boolean used = false; public WsWriter(WsRemoteEndpointImplBase endpoint) { this.endpoint = endpoint; @@ -969,6 +993,7 @@ public abstract class WsRemoteEndpointIm throw new IndexOutOfBoundsException(); } + used = true; if (buffer.remaining() == 0) { flush(); } @@ -991,7 +1016,9 @@ public abstract class WsRemoteEndpointIm sm.getString("wsRemoteEndpoint.closedWriter")); } - doWrite(false); + if (!Constants.STREAMS_DROP_EMPTY_MESSAGES || buffer.position() > 0) { + doWrite(false); + } } @Override @@ -1007,9 +1034,13 @@ public abstract class WsRemoteEndpointIm } private void doWrite(boolean last) throws IOException { - buffer.flip(); - endpoint.sendPartialString(buffer, last); - buffer.clear(); + if (!Constants.STREAMS_DROP_EMPTY_MESSAGES || used) { + buffer.flip(); + endpoint.sendPartialString(buffer, last); + buffer.clear(); + } else { + endpoint.stateMachine.complete(last); + } } } Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsWebSocketContainer.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsWebSocketContainer.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsWebSocketContainer.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/WsWebSocketContainer.java Thu Dec 11 20:10:47 2014 @@ -119,7 +119,7 @@ public class WsWebSocketContainer private int maxTextMessageBufferSize = Constants.DEFAULT_BUFFER_SIZE; private volatile long defaultMaxSessionIdleTimeout = 0; private int backgroundProcessCount = 0; - private int processPeriod = 10; + private int processPeriod = Constants.DEFAULT_PROCESS_PERIOD; @Override @@ -224,8 +224,6 @@ public class WsWebSocketContainer clientEndpointConfiguration.getConfigurator(). beforeRequest(reqHeaders); - ByteBuffer request = createRequest(path, reqHeaders); - SocketAddress sa; if (port == -1) { if ("ws".equalsIgnoreCase(scheme)) { @@ -244,6 +242,16 @@ public class WsWebSocketContainer sa = new InetSocketAddress(host, port); } + // Origin header + if (Constants.DEFAULT_ORIGIN_HEADER_VALUE != null && + !reqHeaders.containsKey(Constants.ORIGIN_HEADER_NAME)) { + List<String> originValues = new ArrayList<>(1); + originValues.add(Constants.DEFAULT_ORIGIN_HEADER_VALUE); + reqHeaders.put(Constants.ORIGIN_HEADER_NAME, originValues); + } + + ByteBuffer request = createRequest(path, reqHeaders); + AsynchronousSocketChannel socketChannel; try { socketChannel = AsynchronousSocketChannel.open(getAsynchronousChannelGroup()); @@ -303,9 +311,8 @@ public class WsWebSocketContainer afterResponse(handshakeResponse); // Sub-protocol - // Header names are always stored in lower case List<String> protocolHeaders = handshakeResponse.getHeaders().get( - Constants.WS_PROTOCOL_HEADER_NAME_LOWER); + Constants.WS_PROTOCOL_HEADER_NAME); if (protocolHeaders == null || protocolHeaders.size() == 0) { subProtocol = null; } else if (protocolHeaders.size() == 1) { @@ -319,7 +326,7 @@ public class WsWebSocketContainer // Should normally only be one header but handle the case of // multiple headers List<String> extHeaders = handshakeResponse.getHeaders().get( - Constants.WS_EXTENSIONS_HEADER_NAME_LOWER); + Constants.WS_EXTENSIONS_HEADER_NAME); if (extHeaders != null) { for (String extHeader : extHeaders) { Util.parseExtensionHeader(extensionsAgreed, extHeader); @@ -371,6 +378,16 @@ public class WsWebSocketContainer endpoint.onOpen(wsSession, clientEndpointConfiguration); registerSession(endpoint, wsSession); + /* It is possible that the server sent one or more messages as soon as + * the WebSocket connection was established. Depending on the exact + * timing of when those messages were sent they could be sat in the + * input buffer waiting to be read and will not trigger a "data + * available to read" event. Therefore, it is necessary to process the + * input buffer here. Note that this happens on the current thread which + * means that this thread will be used for any onMessage notifications. + * This is a special case. Subsequent "data available to read" events + * will be handled by threads from the AsyncChannelGroup's executor. + */ wsFrameClient.startInputProcessing(); return wsSession; @@ -572,7 +589,7 @@ public class WsWebSocketContainer ExecutionException, DeploymentException, EOFException, TimeoutException { - Map<String,List<String>> headers = new HashMap<>(); + Map<String,List<String>> headers = new CaseInsensitiveKeyMap<>(); boolean readStatus = false; boolean readHeaders = false; @@ -839,7 +856,6 @@ public class WsWebSocketContainer public void backgroundProcess() { // This method gets called once a second. backgroundProcessCount ++; - if (backgroundProcessCount >= processPeriod) { backgroundProcessCount = 0; Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMessageHandlerBase.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMessageHandlerBase.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMessageHandlerBase.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMessageHandlerBase.java Thu Dec 11 20:10:47 2014 @@ -116,7 +116,7 @@ public abstract class PojoMessageHandler if (t instanceof RuntimeException) { throw (RuntimeException) t; } else { - throw new RuntimeException(t); + throw new RuntimeException(t.getMessage(), t); } } } Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMethodMapping.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMethodMapping.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMethodMapping.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/pojo/PojoMethodMapping.java Thu Dec 11 20:10:47 2014 @@ -22,6 +22,8 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -64,7 +66,7 @@ public class PojoMethodMapping { private final PojoPathParam[] onOpenParams; private final PojoPathParam[] onCloseParams; private final PojoPathParam[] onErrorParams; - private final Set<MessageHandlerInfo> onMessage = new HashSet<>(); + private final List<MessageHandlerInfo> onMessage = new ArrayList<>(); private final String wsPath; @@ -78,44 +80,106 @@ public class PojoMethodMapping { Method open = null; Method close = null; Method error = null; - for (Method method : clazzPojo.getDeclaredMethods()) { - if (method.getAnnotation(OnOpen.class) != null) { - checkPublic(method); - if (open == null) { - open = method; - } else { - // Duplicate annotation - throw new DeploymentException(sm.getString( - "pojoMethodMapping.duplicateAnnotation", - OnOpen.class, clazzPojo)); - } - } else if (method.getAnnotation(OnClose.class) != null) { - checkPublic(method); - if (close == null) { - close = method; - } else { - // Duplicate annotation - throw new DeploymentException(sm.getString( - "pojoMethodMapping.duplicateAnnotation", - OnClose.class, clazzPojo)); - } - } else if (method.getAnnotation(OnError.class) != null) { - checkPublic(method); - if (error == null) { - error = method; + Method[] clazzPojoMethods = null; + Class<?> currentClazz = clazzPojo; + while (!currentClazz.equals(Object.class)) { + Method[] currentClazzMethods = currentClazz.getDeclaredMethods(); + if (currentClazz == clazzPojo) { + clazzPojoMethods = currentClazzMethods; + } + for (Method method : currentClazzMethods) { + if (method.getAnnotation(OnOpen.class) != null) { + checkPublic(method); + if (open == null) { + open = method; + } else { + if (currentClazz == clazzPojo || + (currentClazz != clazzPojo && !isMethodOverride(open, method))) { + // Duplicate annotation + throw new DeploymentException(sm.getString( + "pojoMethodMapping.duplicateAnnotation", + OnOpen.class, currentClazz)); + } + } + } else if (method.getAnnotation(OnClose.class) != null) { + checkPublic(method); + if (close == null) { + close = method; + } else { + if (currentClazz == clazzPojo || + (currentClazz != clazzPojo && !isMethodOverride(close, method))) { + // Duplicate annotation + throw new DeploymentException(sm.getString( + "pojoMethodMapping.duplicateAnnotation", + OnClose.class, currentClazz)); + } + } + } else if (method.getAnnotation(OnError.class) != null) { + checkPublic(method); + if (error == null) { + error = method; + } else { + if (currentClazz == clazzPojo || + (currentClazz != clazzPojo && !isMethodOverride(error, method))) { + // Duplicate annotation + throw new DeploymentException(sm.getString( + "pojoMethodMapping.duplicateAnnotation", + OnError.class, currentClazz)); + } + } + } else if (method.getAnnotation(OnMessage.class) != null) { + checkPublic(method); + MessageHandlerInfo messageHandler = new MessageHandlerInfo(method, decoders); + boolean found = false; + for (MessageHandlerInfo otherMessageHandler : onMessage) { + if (messageHandler.targetsSameWebSocketMessageType(otherMessageHandler)) { + found = true; + if (currentClazz == clazzPojo || + (currentClazz != clazzPojo + && !isMethodOverride(messageHandler.m, otherMessageHandler.m))) { + // Duplicate annotation + throw new DeploymentException(sm.getString( + "pojoMethodMapping.duplicateAnnotation", + OnMessage.class, currentClazz)); + } + } + } + if (!found) { + onMessage.add(messageHandler); + } } else { - // Duplicate annotation - throw new DeploymentException(sm.getString( - "pojoMethodMapping.duplicateAnnotation", - OnError.class, clazzPojo)); + // Method not annotated } - } else if (method.getAnnotation(OnMessage.class) != null) { - checkPublic(method); - onMessage.add(new MessageHandlerInfo(method, decoders)); - } else { - // Method not annotated + } + currentClazz = currentClazz.getSuperclass(); + } + // If the methods are not on clazzPojo and they are overridden + // by a non annotated method in clazzPojo, they should be ignored + if (open != null && open.getDeclaringClass() != clazzPojo) { + if (isOverridenWithoutAnnotation(clazzPojoMethods, open, OnOpen.class)) { + open = null; + } + } + if (close != null && close.getDeclaringClass() != clazzPojo) { + if (isOverridenWithoutAnnotation(clazzPojoMethods, close, OnClose.class)) { + close = null; + } + } + if (error != null && error.getDeclaringClass() != clazzPojo) { + if (isOverridenWithoutAnnotation(clazzPojoMethods, error, OnError.class)) { + error = null; } } + List<MessageHandlerInfo> overriddenOnMessage = new ArrayList<>(); + for (MessageHandlerInfo messageHandler : onMessage) { + if (messageHandler.m.getDeclaringClass() != clazzPojo + && isOverridenWithoutAnnotation(clazzPojoMethods, messageHandler.m, OnMessage.class)) { + overriddenOnMessage.add(messageHandler); + } + } + for (MessageHandlerInfo messageHandler : overriddenOnMessage) { + onMessage.remove(messageHandler); + } this.onOpen = open; this.onClose = close; this.onError = error; @@ -133,6 +197,25 @@ public class PojoMethodMapping { } + private boolean isMethodOverride(Method method1, Method method2) { + return (method1.getName().equals(method2.getName()) + && method1.getReturnType().equals(method2.getReturnType()) + && Arrays.equals(method1.getParameterTypes(), method2.getParameterTypes())); + } + + + private boolean isOverridenWithoutAnnotation(Method[] methods, + Method superclazzMethod, Class<? extends Annotation> annotation) { + for (Method method : methods) { + if (isMethodOverride(method, superclazzMethod) + && (method.getAnnotation(annotation) == null)) { + return true; + } + } + return false; + } + + public String getWsPath() { return wsPath; } @@ -288,6 +371,7 @@ public class PojoMethodMapping { private int indexInputStream = -1; private int indexReader = -1; private int indexPrimitive = -1; + private Class<?> primitiveType = null; private Map<Integer,PojoPathParam> indexPathParams = new HashMap<>(); private int indexPayload = -1; private DecoderMatch decoderMatch = null; @@ -366,6 +450,7 @@ public class PojoMethodMapping { } else if (Util.isPrimitive(types[i])) { if (indexPrimitive == -1) { indexPrimitive = i; + primitiveType = types[i]; } else { throw new IllegalArgumentException(sm.getString( "pojoMethodMapping.duplicateMessageParam", @@ -470,6 +555,7 @@ public class PojoMethodMapping { // The boolean we found is a payload, not a last flag indexPayload = indexBoolean; indexPrimitive = indexBoolean; + primitiveType = Boolean.TYPE; indexBoolean = -1; } if (indexPayload == -1) { @@ -503,6 +589,40 @@ public class PojoMethodMapping { } + public boolean targetsSameWebSocketMessageType(MessageHandlerInfo otherHandler) { + if (otherHandler == null) { + return false; + } + if (indexByteArray >= 0 && otherHandler.indexByteArray >= 0) { + return true; + } + if (indexByteBuffer >= 0 && otherHandler.indexByteBuffer >= 0) { + return true; + } + if (indexInputStream >= 0 && otherHandler.indexInputStream >= 0) { + return true; + } + if (indexPong >= 0 && otherHandler.indexPong >= 0) { + return true; + } + if (indexPrimitive >= 0 && otherHandler.indexPrimitive >= 0 + && primitiveType == otherHandler.primitiveType) { + return true; + } + if (indexReader >= 0 && otherHandler.indexReader >= 0) { + return true; + } + if (indexString >= 0 && otherHandler.indexString >= 0) { + return true; + } + if (decoderMatch != null && otherHandler.decoderMatch != null + && decoderMatch.getTarget().equals(otherHandler.decoderMatch.getTarget())) { + return true; + } + return false; + } + + public Set<MessageHandler> getMessageHandlers(Object pojo, Map<String,String> pathParameters, Session session, EndpointConfig config) { Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/UpgradeUtil.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/UpgradeUtil.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/UpgradeUtil.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/UpgradeUtil.java Thu Dec 11 20:10:47 2014 @@ -112,7 +112,7 @@ public class UpgradeUtil { // Origin check - String origin = req.getHeader("Origin"); + String origin = req.getHeader(Constants.ORIGIN_HEADER_NAME); if (!sec.getConfigurator().checkOrigin(origin)) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; @@ -134,8 +134,16 @@ public class UpgradeUtil { // Negotiation phase 1. By default this simply filters out the // extensions that the server does not support but applications could // use a custom configurator to do more than this. + List<Extension> installedExtensions = null; + if (sec.getExtensions().size() == 0) { + installedExtensions = Constants.INSTALLED_EXTENSIONS; + } else { + installedExtensions = new ArrayList<>(); + installedExtensions.addAll(sec.getExtensions()); + installedExtensions.addAll(Constants.INSTALLED_EXTENSIONS); + } List<Extension> negotiatedExtensionsPhase1 = sec.getConfigurator().getNegotiatedExtensions( - Constants.INSTALLED_EXTENSIONS, extensionsRequested); + installedExtensions, extensionsRequested); // Negotiation phase 2. Create the Transformations that will be applied // to this connection. Note than an extension may be dropped at this @@ -191,7 +199,7 @@ public class UpgradeUtil { resp.setHeader(Constants.WS_EXTENSIONS_HEADER_NAME, responseHeaderExtensions.toString()); } - WsHandshakeRequest wsRequest = new WsHandshakeRequest(req); + WsHandshakeRequest wsRequest = new WsHandshakeRequest(req, pathParams); WsHandshakeResponse wsResponse = new WsHandshakeResponse(); WsPerSessionServerEndpointConfig perSessionServerEndpointConfig = new WsPerSessionServerEndpointConfig(sec); Modified: tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/WsHandshakeRequest.java URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/WsHandshakeRequest.java?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/WsHandshakeRequest.java (original) +++ tomcat/tc8.0.x/trunk/java/org/apache/tomcat/websocket/server/WsHandshakeRequest.java Thu Dec 11 20:10:47 2014 @@ -30,6 +30,8 @@ import java.util.Map.Entry; import javax.servlet.http.HttpServletRequest; import javax.websocket.server.HandshakeRequest; +import org.apache.tomcat.websocket.CaseInsensitiveKeyMap; + /** * Represents the request that this session was opened under. */ @@ -45,7 +47,7 @@ public class WsHandshakeRequest implemen private volatile HttpServletRequest request; - public WsHandshakeRequest(HttpServletRequest request) { + public WsHandshakeRequest(HttpServletRequest request, Map<String,String> pathParams) { this.request = request; @@ -74,10 +76,15 @@ public class WsHandshakeRequest implemen Collections.unmodifiableList( Arrays.asList(entry.getValue()))); } + for (String pathName : pathParams.keySet()) { + newParameters.put(pathName, + Collections.unmodifiableList( + Arrays.asList(pathParams.get(pathName)))); + } parameterMap = Collections.unmodifiableMap(newParameters); // Headers - Map<String,List<String>> newHeaders = new HashMap<>(); + Map<String,List<String>> newHeaders = new CaseInsensitiveKeyMap<>(); Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { Modified: tomcat/tc8.0.x/trunk/webapps/docs/changelog.xml URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/webapps/docs/changelog.xml?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/webapps/docs/changelog.xml (original) +++ tomcat/tc8.0.x/trunk/webapps/docs/changelog.xml Thu Dec 11 20:10:47 2014 @@ -202,6 +202,27 @@ Correct multiple issues with the flushing of batched messages that could lead to duplicate and/or corrupt messages. (markt) </fix> + <fix> + Correctly implement headers case insensitivity. (markt, remm) + </fix> + <fix> + Allow optional use of user extensions. (remm) + </fix> + <fix> + Allow using partial binary message handlers. (remm) + </fix> + <fix> + Limit ping/pong message size. (remm) + </fix> + <fix> + Allow configuration of the time interval for the periodic event. (remm) + </fix> + <fix> + More accurate annotations processing. (remm) + </fix> + <fix> + Allow optional default for origin header in the client. (remm) + </fix> </changelog> </subsection> <subsection name="Web applications"> Modified: tomcat/tc8.0.x/trunk/webapps/docs/config/systemprops.xml URL: http://svn.apache.org/viewvc/tomcat/tc8.0.x/trunk/webapps/docs/config/systemprops.xml?rev=1644747&r1=1644746&r2=1644747&view=diff ============================================================================== --- tomcat/tc8.0.x/trunk/webapps/docs/config/systemprops.xml (original) +++ tomcat/tc8.0.x/trunk/webapps/docs/config/systemprops.xml Thu Dec 11 20:10:47 2014 @@ -589,6 +589,47 @@ </section> +<section name="Websockets"> + + <properties> + + <property name="org.apache.tomcat .websocket.ALLOW_UNSUPPORTED_EXTENSIONS"> + <p>If <code>true</code>, allow unknown extensions to be declared by + the user.</p> + <p>The default value is <code>false</code>.</p> + </property> + + <property name="org.apache.tomcat. websocket.DEFAULT_ORIGIN_HEADER_VALUE"> + <p>Default value of the origin header that will be sent by the client + during the upgrade handshake.</p> + <p>The default is null so that no origin header is sent.</p> + </property> + + <property name="org.apache.tomcat. websocket.DEFAULT_PROCESS_PERIOD"> + <p>The number of periodic ticks between periodic processing which + involves in particular session expiration checks.</p> + <p>The default value is <code>10</code> which corresponds to 10 + seconds.</p> + </property> + + <property name="org.apache.tomcat. websocket.DISABLE_BUILTIN_EXTENSIONS"> + <p>If <code>true</code>, disable all built-in extensions provided by the + server, such as message compression.</p> + <p>The default value is <code>false</code>.</p> + </property> + + <property name="org.apache.tomcat. websocket.STREAMS_DROP_EMPTY_MESSAGES"> + <p>If <code>true</code>, streams provided to the user (writer and output + stream) will not send an empty message when flushing and there is no + data to flush, or when it is closed without having been used (for + example if an error occurs).</p> + <p>The default value is <code>false</code>.</p> + </property> + + </properties> + +</section> + <section name="Other"> <properties> --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org