Skip to content

Commit ead5361

Browse files
authored
Merge pull request boskworks#84 from prdoyle/tidy-hooks
Isolate `ThreadLocal`s in hooks
2 parents e6a51a2 + b999335 commit ead5361

File tree

3 files changed

+44
-6
lines changed

3 files changed

+44
-6
lines changed

bosk-core/src/main/java/works/bosk/Bosk.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
import java.util.Queue;
1212
import java.util.concurrent.ConcurrentLinkedDeque;
1313
import java.util.concurrent.ConcurrentLinkedQueue;
14+
import java.util.concurrent.ExecutionException;
15+
import java.util.concurrent.ExecutorService;
16+
import java.util.concurrent.Executors;
1417
import java.util.concurrent.Semaphore;
1518
import java.util.concurrent.atomic.AtomicReference;
1619
import java.util.function.BiConsumer;
@@ -89,6 +92,7 @@ public class Bosk<R extends StateTreeNode> implements BoskInfo<R> {
8992
private final RootRef rootRef;
9093
private final ThreadLocal<R> rootSnapshot = new ThreadLocal<>();
9194
private final Queue<HookRegistration<?>> hooks = new ConcurrentLinkedQueue<>();
95+
private final ExecutorService hookExecutor = Executors.newVirtualThreadPerTaskExecutor();
9296
private final PathCompiler pathCompiler;
9397

9498
// Mutable state
@@ -542,8 +546,23 @@ private void drainQueueIfAllowed() {
542546
if (hookExecutionPermit.tryAcquire()) {
543547
try {
544548
for (Runnable ex = hookExecutionQueue.pollFirst(); ex != null; ex = hookExecutionQueue.pollFirst()) {
545-
ex.run();
549+
// Run the task in a separate virtual thread to prevent ThreadLocals from propagating.
550+
// This is slightly tragic, because usually ThreadLocal propagation works just the
551+
// way we'd want, but not always. Given the choices "always, sometimes, never", if
552+
// we can't achieve "always", then the bosk philosophy prefers "never" over "sometimes".
553+
hookExecutor.submit(ex).get();
546554
}
555+
} catch (ExecutionException e) {
556+
if (e.getCause() instanceof RuntimeException r) {
557+
throw r;
558+
} else if (e.getCause() instanceof Error error) {
559+
throw error;
560+
} else {
561+
throw new AssertionError("Hook runnable should catch and wrap checked exceptions", e);
562+
}
563+
} catch (InterruptedException e) {
564+
LOGGER.warn("Interrupted while running hooks", e);
565+
return;
547566
} finally {
548567
hookExecutionPermit.release();
549568
}
@@ -622,7 +641,7 @@ public <T> void registerHook(String name, @NonNull Reference<T> scope, @NonNull
622641

623642
@Override
624643
public void registerHooks(Object receiver) throws InvalidTypeException {
625-
HookRegistrar.registerHooks(receiver, this);
644+
HookScanner.registerHooks(receiver, this);
626645
}
627646

628647
@Override

bosk-core/src/main/java/works/bosk/HookRegistrar.java renamed to bosk-core/src/main/java/works/bosk/HookScanner.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import java.util.ArrayList;
88
import java.util.List;
99
import java.util.function.Function;
10-
import lombok.RequiredArgsConstructor;
1110
import org.slf4j.Logger;
1211
import org.slf4j.LoggerFactory;
1312
import works.bosk.annotations.Hook;
@@ -17,8 +16,10 @@
1716
import static java.lang.reflect.Modifier.isStatic;
1817
import static works.bosk.util.ReflectionHelpers.getDeclaredMethodsInOrder;
1918

20-
@RequiredArgsConstructor
21-
class HookRegistrar {
19+
/**
20+
* Finds methods annotated with {@link Hook} in the given {@code object} and registers them in the given {@link Bosk}.
21+
*/
22+
class HookScanner {
2223
static <T> void registerHooks(T receiverObject, Bosk<?> bosk) throws InvalidTypeException {
2324
int hookCounter = 0;
2425
for (
@@ -91,5 +92,5 @@ static <T> void registerHooks(T receiverObject, Bosk<?> bosk) throws InvalidType
9192
}
9293
}
9394

94-
private static final Logger LOGGER = LoggerFactory.getLogger(HookRegistrar.class);
95+
private static final Logger LOGGER = LoggerFactory.getLogger(HookScanner.class);
9596
}

bosk-core/src/test/java/works/bosk/HooksTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import java.lang.reflect.InvocationTargetException;
55
import java.util.ArrayList;
66
import java.util.List;
7+
import java.util.concurrent.BlockingQueue;
8+
import java.util.concurrent.LinkedBlockingQueue;
79
import java.util.concurrent.atomic.AtomicBoolean;
810
import org.junit.jupiter.api.BeforeEach;
911
import org.junit.jupiter.api.Test;
@@ -78,6 +80,22 @@ void basic_hooksRunWhenRegistered() {
7880
"Hook should fire when it's registered");
7981
}
8082

83+
@Test
84+
void basic_hooksDoNotPropagateThreadLocals() throws InterruptedException {
85+
ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "initial");
86+
threadLocal.set("updated");
87+
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
88+
bosk.registerHook("foo", bosk.rootReference(), ref -> {
89+
try {
90+
queue.put(threadLocal.get());
91+
} catch (InterruptedException e) {
92+
throw new AssertionError("Huh?", e);
93+
}
94+
});
95+
String observed = queue.take();
96+
assertEquals("initial", observed, "Thread locals should not propagate into hooks");
97+
}
98+
8199
@ParameterizedTest
82100
@EnumSource(Variant.class)
83101
void basic_noIrrelevantHooks(Variant variant) {

0 commit comments

Comments
 (0)