Skip to content

Commit b6d09eb

Browse files
authored
feat: Add Channels.closeShield for close-shield proxies of NIO channels (#786)
* feat: Add `Channels.closeShield` for close-shield proxies of NIO channels Adds `Channels.closeShield(Channel)` to return a JDK proxy that preserves the delegate’s `Channel` sub-interfaces and shields the underlying channel from `close()`. **Behavior** * `close()` flips shield state; delegate is not closed. * `isOpen()` reflects shield state. * After shield-close, I/O/mutating methods throw `ClosedChannelException`; safe queries (e.g., `NetworkChannel.supportedOptions()`) still work. * Fluent methods that return `this` (e.g., `SeekableByteChannel.position/truncate`, `NetworkChannel.bind/setOption`) return the **proxy**. * Stable `equals`/`hashCode`/`toString` * The method is idempotent (no double-wrapping). **Tests** * All methods of `SeekableByteChannel` / `NetworkChannel` and super-interfaces are tested. * fix: rename to `CloseShieldChannel.wrap`
1 parent 3e736fe commit b6d09eb

File tree

4 files changed

+489
-0
lines changed

4 files changed

+489
-0
lines changed

src/changes/changes.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ The <action> type attribute can be add,update,fix,remove.
6666
<action dev="pkarwasz" type="add" due-to="Piotr P. Karwasz">Add org.apache.commons.io.file.PathUtils.getPath(String, String).</action>
6767
<action dev="ggregory" type="add" due-to="Gary Gregory">Add org.apache.commons.io.channels.ByteArraySeekableByteChannel.</action>
6868
<action dev="ggregory" type="add" due-to="Gary Gregory">Add IOIterable.asIterable().</action>
69+
<action dev="pkarwasz" type="add" due-to="Piotr P. Karwasz">Add Channels.closeShield(Channel) for close-shielded NIO Channel proxies.</action>
6970
<!-- UPDATE -->
7071
<action type="update" dev="ggregory" due-to="Gary Gregory, Dependabot">Bump org.apache.commons:commons-parent from 85 to 88 #774, #783.</action>
7172
<action type="update" dev="ggregory" due-to="Gary Gregory">[test] Bump commons-codec:commons-codec from 1.18.0 to 1.19.0.</action>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.commons.io.channels;
18+
19+
import java.lang.reflect.InvocationHandler;
20+
import java.lang.reflect.Proxy;
21+
import java.nio.channels.Channel;
22+
import java.util.LinkedHashSet;
23+
import java.util.Objects;
24+
import java.util.Set;
25+
26+
/**
27+
* Utility to create a close-shielding proxy for a {@link Channel}.
28+
*
29+
* <p>The returned proxy will implement all {@link Channel} sub-interfaces that the delegate implements.</p>
30+
*
31+
* @since 2.21.0
32+
*/
33+
public final class CloseShieldChannel {
34+
35+
private CloseShieldChannel() {
36+
// no instance
37+
}
38+
39+
/**
40+
* Wraps a channel to shield it from being closed.
41+
*
42+
* @param channel The underlying channel to shield, not {@code null}.
43+
* @param <T> Any Channel type (interface or class).
44+
* @return A proxy that shields {@code close()} and enforces closed semantics on other calls.
45+
*/
46+
@SuppressWarnings("unchecked")
47+
public static <T extends Channel> T wrap(final T channel) {
48+
Objects.requireNonNull(channel, "channel");
49+
50+
// Fast path: already our shield
51+
if (Proxy.isProxyClass(channel.getClass())) {
52+
final InvocationHandler handler = Proxy.getInvocationHandler(channel);
53+
if (handler instanceof CloseShieldChannelHandler) {
54+
return channel;
55+
}
56+
}
57+
58+
// Collect only Channel sub-interfaces.
59+
Class<?>[] ifaces = collectChannelInterfaces(channel.getClass());
60+
if (ifaces.length == 0) {
61+
ifaces = new Class<?>[] {Channel.class}; // fallback to minimal surface
62+
}
63+
64+
return (T) Proxy.newProxyInstance(
65+
channel.getClass().getClassLoader(), // use delegate's loader
66+
ifaces,
67+
new CloseShieldChannelHandler(channel));
68+
}
69+
70+
private static Class<?>[] collectChannelInterfaces(final Class<?> type) {
71+
final Set<Class<?>> out = new LinkedHashSet<>();
72+
collectChannelInterfaces(type, out);
73+
return out.toArray(new Class<?>[0]);
74+
}
75+
76+
private static void collectChannelInterfaces(final Class<?> type, final Set<Class<?>> out) {
77+
// Visit interfaces
78+
for (Class<?> iface : type.getInterfaces()) {
79+
if (Channel.class.isAssignableFrom(iface) && out.add(iface)) {
80+
collectChannelInterfaces(iface, out);
81+
}
82+
}
83+
}
84+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.commons.io.channels;
18+
19+
import java.lang.reflect.InvocationHandler;
20+
import java.lang.reflect.InvocationTargetException;
21+
import java.lang.reflect.Method;
22+
import java.lang.reflect.Proxy;
23+
import java.nio.channels.Channel;
24+
import java.nio.channels.ClosedChannelException;
25+
import java.nio.channels.NetworkChannel;
26+
import java.nio.channels.SeekableByteChannel;
27+
import java.util.Objects;
28+
29+
final class CloseShieldChannelHandler implements InvocationHandler {
30+
31+
private final Channel delegate;
32+
private volatile boolean closed;
33+
34+
CloseShieldChannelHandler(final Channel delegate) {
35+
this.delegate = Objects.requireNonNull(delegate, "delegate");
36+
}
37+
38+
@Override
39+
public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
40+
final Class<?> declaringClass = method.getDeclaringClass();
41+
final String name = method.getName();
42+
final int parameterCount = method.getParameterCount();
43+
44+
// 1) java.lang.Object methods
45+
if (declaringClass == Object.class) {
46+
return invokeObjectMethod(proxy, method, args);
47+
}
48+
49+
// 2) Channel.close(): mark shield closed, do NOT close the delegate
50+
if (parameterCount == 0 && name.equals("close")) {
51+
closed = true;
52+
return null;
53+
}
54+
55+
// 3) Channel.isOpen(): reflect shield state only
56+
if (parameterCount == 0 && name.equals("isOpen")) {
57+
return !closed && delegate.isOpen();
58+
}
59+
60+
// 4) After the shield is closed, only allow a tiny allowlist of safe queries
61+
if (closed && !isAllowedAfterClose(declaringClass, name, parameterCount)) {
62+
throw new ClosedChannelException();
63+
}
64+
65+
// 5) Delegate to the underlying channel and unwrap target exceptions
66+
try {
67+
final Object result = method.invoke(delegate, args);
68+
return returnsThis(declaringClass, name, parameterCount) ? proxy : result;
69+
} catch (InvocationTargetException e) {
70+
throw e.getCause();
71+
}
72+
}
73+
74+
/**
75+
* Tests whether the given method is allowed to be called after the shield is closed.
76+
*
77+
* @param declaringClass The class declaring the method.
78+
* @param name The method name.
79+
* @param parameterCount The number of parameters.
80+
* @return {@code true} if the method is allowed after {@code close()}, {@code false} otherwise.
81+
*/
82+
private static boolean isAllowedAfterClose(Class<?> declaringClass, String name, int parameterCount) {
83+
// JDK explicitly allows NetworkChannel.supportedOptions() post-close
84+
return parameterCount == 0 && name.equals("supportedOptions") && NetworkChannel.class.equals(declaringClass);
85+
}
86+
87+
/**
88+
* Tests whether the given method returns 'this' (the channel) as per JDK spec.
89+
*
90+
* @param declaringClass The class declaring the method.
91+
* @param name The method name.
92+
* @param parameterCount The number of parameters.
93+
* @return {@code true} if the method returns 'this', {@code false} otherwise.
94+
*/
95+
private static boolean returnsThis(Class<?> declaringClass, String name, int parameterCount) {
96+
if (SeekableByteChannel.class.equals(declaringClass)) {
97+
// SeekableByteChannel.position(long) and truncate(long) return 'this'
98+
return parameterCount == 1 && (name.equals("position") || name.equals("truncate"));
99+
}
100+
if (NetworkChannel.class.equals(declaringClass)) {
101+
// NetworkChannel.bind and NetworkChannel.setOption returns 'this'
102+
return parameterCount == 1 && name.equals("bind") || parameterCount == 2 && name.equals("setOption");
103+
}
104+
return false;
105+
}
106+
107+
private Object invokeObjectMethod(final Object proxy, final Method method, final Object[] args)
108+
throws ReflectiveOperationException {
109+
switch (method.getName()) {
110+
case "toString":
111+
return "CloseShield(" + delegate + ")";
112+
case "hashCode":
113+
return Objects.hashCode(delegate);
114+
case "equals": {
115+
final Object other = args[0];
116+
if (other == null) {
117+
return false;
118+
}
119+
if (proxy == other) {
120+
return true;
121+
}
122+
if (Proxy.isProxyClass(other.getClass())) {
123+
final InvocationHandler h = Proxy.getInvocationHandler(other);
124+
if (h instanceof CloseShieldChannelHandler) {
125+
return Objects.equals(((CloseShieldChannelHandler) h).delegate, this.delegate);
126+
}
127+
}
128+
return false;
129+
}
130+
default:
131+
// Not possible, all non-final Object methods are handled above
132+
return null;
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)