Add virtual-thread, structured-concurrency demos for the Spring Security context propagation guide
Three verified programs (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency versions) backing the ankurm.com post: InheritableThreadLocal across thread models, @Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator), and StructuredTaskScope.fork() propagation. Captured console output and docs chapters included.
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
cp.txt
|
||||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# spring-security-demo
|
||||||
|
|
||||||
|
Companion repo for [Spring Security Context Propagation - Complete
|
||||||
|
Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) on ankurm.com.
|
||||||
|
Three small, dependency-light programs that answer one question each: does a Spring Security
|
||||||
|
`SecurityContext` survive a specific thread hand-off? No web server, no HTTP -- every scenario
|
||||||
|
sets an `Authentication` on the calling thread and checks whether the other side of the hand-off
|
||||||
|
can see it.
|
||||||
|
|
||||||
|
## Verified versions
|
||||||
|
|
||||||
|
| Component | Version |
|
||||||
|
|---|---|
|
||||||
|
| JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 |
|
||||||
|
| Spring Boot (reference target) | 4.1.1 |
|
||||||
|
| Spring Framework | 7.0.9 |
|
||||||
|
| Spring Security | 7.1.1 |
|
||||||
|
| `io.micrometer:context-propagation` | 1.2.1 (as managed by Boot 4.1.1's BOM) |
|
||||||
|
|
||||||
|
`StructuredTaskScope` is a **preview API** on JDK 25 (JEP 505, fifth preview) and remains
|
||||||
|
preview through JDK 26 (JEP 525, sixth preview) -- every build/run command below needs
|
||||||
|
`--enable-preview`.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
|
||||||
|
javac --release 25 --enable-preview -cp "$(cat cp.txt)" -d target/classes $(find src -name '*.java')
|
||||||
|
java --enable-preview -cp "target/classes:$(cat cp.txt)" com.ankurm.vt.Demo1PlainThreadLocal
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just run everything and regenerate the captured output: `scripts/run-all.sh`.
|
||||||
|
|
||||||
|
## What each demo shows
|
||||||
|
|
||||||
|
| Demo | Question | Chapter |
|
||||||
|
|---|---|---|
|
||||||
|
| `Demo1PlainThreadLocal` | Does `InheritableThreadLocal` behave differently for a pooled platform thread vs. a fresh virtual thread? (No Spring.) | [docs/01](docs/01-inheritable-threadlocal.md) |
|
||||||
|
| `Demo2AsyncVirtualThreads` | Does the Boot-4.1-style virtual-thread `@Async` executor propagate `SecurityContext`, and what four fixes change? | [docs/02](docs/02-async-virtual-threads.md) |
|
||||||
|
| `Demo3StructuredConcurrency` | Does a `StructuredTaskScope.fork()` subtask see the parent's `SecurityContext`? | [docs/03](docs/03-structured-concurrency.md) |
|
||||||
|
|
||||||
|
## Captured output
|
||||||
|
|
||||||
|
Every number and log line in the blog post traces back to one of these, produced by
|
||||||
|
`scripts/run-all.sh`, not retyped:
|
||||||
|
|
||||||
|
- [docs/output/demo1.txt](docs/output/demo1.txt)
|
||||||
|
- [docs/output/demo2.txt](docs/output/demo2.txt)
|
||||||
|
- [docs/output/demo3.txt](docs/output/demo3.txt)
|
||||||
|
|
||||||
|
## The one-line summary of all three chapters
|
||||||
|
|
||||||
|
`SecurityContextHolder` is a `ThreadLocal`. Nothing about virtual threads or structured
|
||||||
|
concurrency changes that. What changed is that virtual threads are never pooled, so
|
||||||
|
`MODE_INHERITABLETHREADLOCAL`'s old danger (stale context on a reused pool worker) doesn't
|
||||||
|
apply to them -- and Spring Security 6.5 gave `SecurityContextHolder` a Micrometer
|
||||||
|
`ThreadLocalAccessor`, so `ContextPropagatingTaskDecorator` / `ContextSnapshot.wrap(...)` now
|
||||||
|
propagate it automatically, alongside MDC and tracing context, without a
|
||||||
|
`DelegatingSecurityContext*` wrapper.
|
||||||
39
docs/01-inheritable-threadlocal.md
Normal file
39
docs/01-inheritable-threadlocal.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 1. Why InheritableThreadLocal behaves differently with virtual threads
|
||||||
|
|
||||||
|
[Next: Async + virtual threads →](02-async-virtual-threads.md)
|
||||||
|
|
||||||
|
`Demo1PlainThreadLocal.java` has no Spring in it at all. It exists to settle one question
|
||||||
|
before Spring Security enters the picture: does `InheritableThreadLocal` actually behave
|
||||||
|
differently once the thread on the other end is virtual?
|
||||||
|
|
||||||
|
## The three cases
|
||||||
|
|
||||||
|
Every `Thread` copies the creating thread's `InheritableThreadLocal` values **once, at
|
||||||
|
construction time**. That single sentence explains everything Spring Security's concurrency
|
||||||
|
support has ever had to work around:
|
||||||
|
|
||||||
|
- A **fresh platform `Thread`** picks up whatever was set on the thread that created it. Fine.
|
||||||
|
- A **pooled platform thread** was constructed once, long ago, by the pool's internal thread
|
||||||
|
factory. Every task submitted to it later runs on that same physical thread, so it keeps
|
||||||
|
whatever `InheritableThreadLocal` value existed *when the pool created the worker*, not
|
||||||
|
what the submitting thread had at submission time. `docs/output/demo1.txt` shows this
|
||||||
|
directly: task 2 on a reused pool worker still reports `request-B`, not `request-C`, even
|
||||||
|
though the caller updated the value in between.
|
||||||
|
- A **virtual thread** is, in this respect, identical to the fresh-platform-thread case.
|
||||||
|
`Executors.newVirtualThreadPerTaskExecutor()` and `Thread.ofVirtual().start(...)` both
|
||||||
|
construct a brand new `Thread` object per task -- virtual threads are never pooled or
|
||||||
|
reused the way platform worker threads are. So the "stale value from a reused thread"
|
||||||
|
failure mode that made `SecurityContextHolder.MODE_INHERITABLETHREADLOCAL` dangerous with
|
||||||
|
`ThreadPoolTaskExecutor` simply does not exist for virtual threads.
|
||||||
|
|
||||||
|
## Why this matters for the rest of the repo
|
||||||
|
|
||||||
|
Spring Security's docs (and the original version of the blog post this repo supports) warn
|
||||||
|
against `MODE_INHERITABLETHREADLOCAL` because of the pooled-thread case above. That warning
|
||||||
|
is correct for `ThreadPoolTaskExecutor`. It stops being the relevant risk once
|
||||||
|
`spring.threads.virtual.enabled=true` swaps the executor for a `SimpleAsyncTaskExecutor`
|
||||||
|
backed by virtual threads -- there is no pool left to go stale. [Chapter 2](02-async-virtual-threads.md)
|
||||||
|
verifies that directly against `SecurityContextHolder`.
|
||||||
|
|
||||||
|
Run it yourself: `scripts/run-all.sh`, or just `demo1` from the output already captured in
|
||||||
|
[`docs/output/demo1.txt`](output/demo1.txt).
|
||||||
67
docs/02-async-virtual-threads.md
Normal file
67
docs/02-async-virtual-threads.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 2. @Async, DelegatingSecurityContextExecutor, and virtual threads on Boot 4.1
|
||||||
|
|
||||||
|
[← Prev: InheritableThreadLocal](01-inheritable-threadlocal.md) | [Next: Structured concurrency →](03-structured-concurrency.md)
|
||||||
|
|
||||||
|
`Demo2AsyncVirtualThreads.java` reproduces the exact executor bean Spring Boot 4.1 wires up
|
||||||
|
when you set `spring.threads.virtual.enabled=true`: a `SimpleAsyncTaskExecutor` with
|
||||||
|
`setVirtualThreads(true)`. That bean backs `@Async`, MVC async request handling, and WebFlux's
|
||||||
|
blocking-execution support. It is not a `ThreadPoolTaskExecutor` and never has a fixed pool of
|
||||||
|
workers to reuse -- see [Chapter 1](01-inheritable-threadlocal.md) for why that matters.
|
||||||
|
|
||||||
|
Four scenarios, same question each time: does the async task see the `Authentication` that was
|
||||||
|
active on the calling thread? Full output in [`docs/output/demo2.txt`](output/demo2.txt).
|
||||||
|
|
||||||
|
## A) Default mode, unwrapped executor -- loses it
|
||||||
|
|
||||||
|
`SecurityContextHolder`'s default strategy, `MODE_THREADLOCAL`, does not travel to any new
|
||||||
|
thread, virtual or not. This is the exact symptom reported against Spring Security as
|
||||||
|
[gh-15040](https://github.com/spring-projects/spring-security/issues/15040): swap in a raw
|
||||||
|
virtual-thread executor and `@Async` methods start throwing `AccessDeniedException` because
|
||||||
|
`SecurityContextHolder.getContext().getAuthentication()` is `null`.
|
||||||
|
|
||||||
|
## B) MODE_INHERITABLETHREADLOCAL, unwrapped executor -- works
|
||||||
|
|
||||||
|
This is the finding from Chapter 1 applied to Spring Security directly. Because the virtual
|
||||||
|
thread the executor spins up is fresh every time, `MODE_INHERITABLETHREADLOCAL` propagates the
|
||||||
|
context correctly with **zero extra wrapping code**. The reference docs' warning against this
|
||||||
|
mode predates virtual threads and is about pooled platform threads specifically -- it does not
|
||||||
|
apply to this executor shape. This is still a global JVM-wide setting, so weigh that against the
|
||||||
|
next two options, which are scoped to one executor bean.
|
||||||
|
|
||||||
|
## C) DelegatingSecurityContextExecutor -- still works, unconditionally
|
||||||
|
|
||||||
|
`DelegatingSecurityContextExecutor` doesn't rely on thread-local inheritance at all -- it wraps
|
||||||
|
the submitted `Runnable`, and the wrapper explicitly calls
|
||||||
|
`SecurityContextHolder.setContext(...)` / `clearContext()` around the delegate's `run()`,
|
||||||
|
wherever that `run()` happens to execute. That is why it has worked, unchanged, since long
|
||||||
|
before virtual threads existed, and why it is still the correct choice for library code that
|
||||||
|
cannot assume the application has set `MODE_INHERITABLETHREADLOCAL` globally.
|
||||||
|
|
||||||
|
## D) ContextPropagatingTaskDecorator -- the mechanism that's actually new
|
||||||
|
|
||||||
|
This is the one that did not exist when the [original version of this
|
||||||
|
post](https://ankurm.com/spring-security-context-propagation-complete-guide/) went up.
|
||||||
|
Spring Security 6.5 (GA 2025-05-19) added `SecurityContextHolderThreadLocalAccessor`, which
|
||||||
|
self-registers with Micrometer's `ContextRegistry` via `ServiceLoader` the moment
|
||||||
|
`io.micrometer:context-propagation` is on the classpath -- no bean, no configuration. Spring
|
||||||
|
Framework's `ContextPropagatingTaskDecorator` (since 6.1) uses that registry to snapshot and
|
||||||
|
restore every registered `ThreadLocalAccessor` around a task. Set it as the executor's task
|
||||||
|
decorator and `@Async` methods get the `SecurityContext` back **without any
|
||||||
|
`DelegatingSecurityContext*` wrapper at all** -- and the same decorator simultaneously restores
|
||||||
|
MDC and tracing context, which the `Delegating*` classes never touched.
|
||||||
|
|
||||||
|
```java
|
||||||
|
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
|
||||||
|
executor.setVirtualThreads(true);
|
||||||
|
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||||
|
```
|
||||||
|
|
||||||
|
Spring Security's own [Concurrency Support
|
||||||
|
page](https://docs.spring.io/spring-security/reference/features/integrations/concurrency.html)
|
||||||
|
still only documents the `Delegating*` family as of 7.1.1 -- this pattern is real and shipped,
|
||||||
|
just not yet reflected in that page.
|
||||||
|
|
||||||
|
`io.micrometer:context-propagation` is already on the classpath of any Boot 4.1 app that pulls
|
||||||
|
in `micrometer-observation` (actuator, tracing, or `spring-boot-starter-micrometer-*`). If your
|
||||||
|
app doesn't have a Micrometer dependency anywhere, add
|
||||||
|
`io.micrometer:context-propagation:1.2.1` (the version Boot 4.1.1's BOM manages) explicitly.
|
||||||
61
docs/03-structured-concurrency.md
Normal file
61
docs/03-structured-concurrency.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 3. StructuredTaskScope and SecurityContext
|
||||||
|
|
||||||
|
[← Prev: Async + virtual threads](02-async-virtual-threads.md)
|
||||||
|
|
||||||
|
`Demo3StructuredConcurrency.java` asks the Chapter 2 question again, but for
|
||||||
|
`StructuredTaskScope` (JEP 505, fifth preview in JDK 25 -- still preview through the JDK 26
|
||||||
|
sixth preview per JEP 525, so every example here needs `--enable-preview`). A `fork()` call
|
||||||
|
starts a brand new virtual thread for the subtask, same as the executors in Chapter 2, so the
|
||||||
|
Chapter 1 finding applies here too. Full output in
|
||||||
|
[`docs/output/demo3.txt`](output/demo3.txt).
|
||||||
|
|
||||||
|
## What the JEP actually promises
|
||||||
|
|
||||||
|
JEP 525's text is explicit about one kind of context and silent about another:
|
||||||
|
|
||||||
|
> Subtasks forked in a scope inherit `ScopedValue` bindings.
|
||||||
|
|
||||||
|
That is a real, specified guarantee -- and it says nothing about `ThreadLocal`. Spring
|
||||||
|
Security's `SecurityContextHolder` is a `ThreadLocal`/`InheritableThreadLocal`, not a
|
||||||
|
`ScopedValue`. Nothing in the structured concurrency API changes that, and scenario A below
|
||||||
|
proves it: a plain `scope.fork(...)` with the default `MODE_THREADLOCAL` strategy loses the
|
||||||
|
`Authentication` exactly like the unwrapped executor in Chapter 2 did.
|
||||||
|
|
||||||
|
## Four scenarios
|
||||||
|
|
||||||
|
- **A) Plain fork, MODE_THREADLOCAL** -- lost. The default `SecurityContextHolder` strategy
|
||||||
|
isn't inherited by anything, structured concurrency included.
|
||||||
|
- **B) Plain fork, MODE_INHERITABLETHREADLOCAL** -- propagates. Same reasoning as Chapter 2,
|
||||||
|
scenario B: `fork()`'s subtask thread is a fresh virtual thread, so inheritance at
|
||||||
|
construction time works and there is no pooled-thread staleness risk.
|
||||||
|
- **C) Manual capture-and-restore around the forked `Callable`** -- propagates, and does not
|
||||||
|
depend on the global strategy mode at all:
|
||||||
|
|
||||||
|
```java
|
||||||
|
SecurityContext captured = SecurityContextHolder.getContext();
|
||||||
|
Callable<String> task = () -> {
|
||||||
|
SecurityContextHolder.setContext(captured);
|
||||||
|
try { return doWork(); }
|
||||||
|
finally { SecurityContextHolder.clearContext(); }
|
||||||
|
};
|
||||||
|
scope.fork(task);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the safest pattern for a `StructuredTaskScope` used inside library code, the same
|
||||||
|
way `DelegatingSecurityContextExecutor` is the safest pattern for an `Executor`: it works
|
||||||
|
regardless of what the surrounding application has set `SecurityContextHolder`'s strategy to.
|
||||||
|
|
||||||
|
- **D) `ContextSnapshot.wrap(...)` around the forked `Callable`** -- the Chapter 2 mechanism
|
||||||
|
applied to `fork()` instead of `execute()`. Because `SecurityContextHolderThreadLocalAccessor`
|
||||||
|
is already registered with Micrometer's `ContextRegistry`, `ContextSnapshotFactory.builder()
|
||||||
|
.build().captureAll()` picks up the current `SecurityContext` (and MDC, and tracing context)
|
||||||
|
in one call, and `.wrap(callable)` restores all of them inside the subtask. This is the
|
||||||
|
version worth reaching for once you have more than the `SecurityContext` to carry across the
|
||||||
|
scope boundary.
|
||||||
|
|
||||||
|
## The practical takeaway
|
||||||
|
|
||||||
|
`StructuredTaskScope` does not give `SecurityContextHolder` anything for free. If your
|
||||||
|
`fork()`ed subtasks need to call secured services, wrap them explicitly -- option C if you
|
||||||
|
want zero new dependencies, option D if `context-propagation` is already on the classpath and
|
||||||
|
you have other thread-locals to carry along too.
|
||||||
5
docs/output/demo1.txt
Normal file
5
docs/output/demo1.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
=== Demo 1: InheritableThreadLocal across thread models ===
|
||||||
|
fresh platform thread sees: request-A
|
||||||
|
pool thread, task 1, sees: request-B
|
||||||
|
pool thread, task 2 (reused), sees: request-B <-- stale, not request-C
|
||||||
|
fresh virtual thread sees: request-D
|
||||||
6
docs/output/demo2.txt
Normal file
6
docs/output/demo2.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
=== Demo 2: @Async-style virtual thread executor + SecurityContext ===
|
||||||
|
A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): NO AUTHENTICATION (lost) [VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||||
|
B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): authenticated as bob [VirtualThread[#26,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||||
|
C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual): authenticated as carol [VirtualThread[#27,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||||
|
SecurityContextHolderThreadLocalAccessor present: true
|
||||||
|
D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper: authenticated as dave [VirtualThread[#28,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||||
5
docs/output/demo3.txt
Normal file
5
docs/output/demo3.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===
|
||||||
|
A) plain fork, MODE_THREADLOCAL: NO AUTHENTICATION (lost)
|
||||||
|
B) plain fork, MODE_INHERITABLETHREADLOCAL: authenticated as frank
|
||||||
|
C) manual capture/restore: authenticated as grace
|
||||||
|
D) ContextSnapshot.wrap: authenticated as heidi
|
||||||
51
pom.xml
Normal file
51
pom.xml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>vt-verify</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>25</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-core</artifactId>
|
||||||
|
<version>7.1.1</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-core</artifactId>
|
||||||
|
<version>7.0.9</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-context</artifactId>
|
||||||
|
<version>7.0.9</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>context-propagation</artifactId>
|
||||||
|
<version>1.2.1</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<release>25</release>
|
||||||
|
<compilerArgs>
|
||||||
|
<arg>--enable-preview</arg>
|
||||||
|
</compilerArgs>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
19
scripts/run-all.sh
Executable file
19
scripts/run-all.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles and runs all three demos, regenerating docs/output/*.txt.
|
||||||
|
# Requires JDK 25 (StructuredTaskScope is a preview API through JDK 25/JEP 505).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
|
||||||
|
CP=$(cat cp.txt)
|
||||||
|
|
||||||
|
rm -rf target/classes
|
||||||
|
mkdir -p target/classes docs/output
|
||||||
|
javac --release 25 --enable-preview -cp "$CP" -d target/classes $(find src -name '*.java')
|
||||||
|
|
||||||
|
for demo in Demo1PlainThreadLocal Demo2AsyncVirtualThreads Demo3StructuredConcurrency; do
|
||||||
|
num=$(echo "$demo" | grep -o '[0-9]')
|
||||||
|
out="docs/output/demo${num}.txt"
|
||||||
|
echo "Running $demo -> $out"
|
||||||
|
java --enable-preview -cp "target/classes:$CP" "com.ankurm.vt.$demo" | tee "$out"
|
||||||
|
done
|
||||||
44
src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java
Normal file
44
src/main/java/com/ankurm/vt/Demo1PlainThreadLocal.java
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ankurm.vt;
|
||||||
|
|
||||||
|
// Explained in docs/01-inheritable-threadlocal.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Confirms, with no Spring involved, how InheritableThreadLocal behaves for
|
||||||
|
* (a) a fresh platform Thread, (b) a reused thread from a fixed pool, and
|
||||||
|
* (c) a fresh virtual thread. */
|
||||||
|
public class Demo1PlainThreadLocal {
|
||||||
|
|
||||||
|
static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>();
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.out.println("=== Demo 1: InheritableThreadLocal across thread models ===");
|
||||||
|
|
||||||
|
// (a) fresh platform Thread inherits at construction time
|
||||||
|
CTX.set("request-A");
|
||||||
|
Thread t = new Thread(() -> System.out.println("fresh platform thread sees: " + CTX.get()));
|
||||||
|
t.start();
|
||||||
|
t.join();
|
||||||
|
|
||||||
|
// (b) reused thread from a fixed pool: the SECOND task on the same worker
|
||||||
|
// still carries whatever was set when the pool thread was originally created
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(1);
|
||||||
|
CTX.set("request-B");
|
||||||
|
pool.submit(() -> System.out.println("pool thread, task 1, sees: " + CTX.get())).get();
|
||||||
|
CTX.set("request-C"); // caller's context changed
|
||||||
|
pool.submit(() -> System.out.println("pool thread, task 2 (reused), sees: " + CTX.get()
|
||||||
|
+ " <-- stale, not request-C")).get();
|
||||||
|
pool.shutdown();
|
||||||
|
|
||||||
|
// (c) fresh virtual thread, never reused
|
||||||
|
CTX.set("request-D");
|
||||||
|
Thread vt = Thread.ofVirtual().start(() ->
|
||||||
|
System.out.println("fresh virtual thread sees: " + CTX.get()));
|
||||||
|
vt.join();
|
||||||
|
|
||||||
|
CTX.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java
Normal file
93
src/main/java/com/ankurm/vt/Demo2AsyncVirtualThreads.java
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package com.ankurm.vt;
|
||||||
|
|
||||||
|
// Explained in docs/02-async-virtual-threads.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||||
|
|
||||||
|
import org.springframework.core.task.AsyncTaskExecutor;
|
||||||
|
import org.springframework.core.task.SimpleAsyncTaskExecutor;
|
||||||
|
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
|
||||||
|
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Reproduces the exact bean shape Boot 4.1 creates when
|
||||||
|
* spring.threads.virtual.enabled=true: a SimpleAsyncTaskExecutor backed by
|
||||||
|
* Thread.ofVirtual(). Shows what SecurityContextHolder.MODE_THREADLOCAL (the
|
||||||
|
* Spring Security default) does and does not propagate into it, and what
|
||||||
|
* three different fixes change. */
|
||||||
|
public class Demo2AsyncVirtualThreads {
|
||||||
|
|
||||||
|
static SimpleAsyncTaskExecutor bootStyleVirtualThreadExecutor() {
|
||||||
|
SimpleAsyncTaskExecutor exec = new SimpleAsyncTaskExecutor("vt-");
|
||||||
|
exec.setVirtualThreads(true); // what spring.threads.virtual.enabled=true wires up
|
||||||
|
return exec;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Authentication auth(String name) {
|
||||||
|
return UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void run(String label, Executor executor) throws InterruptedException {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
executor.execute(() -> {
|
||||||
|
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
System.out.println(label + ": " + (a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName())
|
||||||
|
+ " [" + Thread.currentThread() + "]");
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
latch.await(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.out.println("=== Demo 2: @Async-style virtual thread executor + SecurityContext ===");
|
||||||
|
|
||||||
|
// --- Scenario A: default MODE_THREADLOCAL, unwrapped virtual-thread executor ---
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
|
||||||
|
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||||
|
ctx.setAuthentication(auth("alice"));
|
||||||
|
SecurityContextHolder.setContext(ctx);
|
||||||
|
run("A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
|
// --- Scenario B: MODE_INHERITABLETHREADLOCAL, same raw executor ---
|
||||||
|
// The historical warning against this mode is about REUSED pool threads.
|
||||||
|
// SimpleAsyncTaskExecutor with virtual threads never reuses a thread, so
|
||||||
|
// the usual danger doesn't apply here -- verifying that directly.
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
||||||
|
ctx = SecurityContextHolder.createEmptyContext();
|
||||||
|
ctx.setAuthentication(auth("bob"));
|
||||||
|
SecurityContextHolder.setContext(ctx);
|
||||||
|
run("B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual)", bootStyleVirtualThreadExecutor());
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset default
|
||||||
|
|
||||||
|
// --- Scenario C: DelegatingSecurityContextExecutor wrapping the virtual-thread executor ---
|
||||||
|
ctx = SecurityContextHolder.createEmptyContext();
|
||||||
|
ctx.setAuthentication(auth("carol"));
|
||||||
|
SecurityContextHolder.setContext(ctx);
|
||||||
|
Executor wrapped = new DelegatingSecurityContextExecutor(bootStyleVirtualThreadExecutor());
|
||||||
|
run("C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual)", wrapped);
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
|
// --- Scenario D: ContextPropagatingTaskDecorator + SecurityContextHolderThreadLocalAccessor ---
|
||||||
|
// Confirms the accessor is really registered with Micrometer's ContextRegistry
|
||||||
|
// (it self-registers via ServiceLoader when context-propagation is on the classpath).
|
||||||
|
System.out.println("SecurityContextHolderThreadLocalAccessor present: "
|
||||||
|
+ (new SecurityContextHolderThreadLocalAccessor() != null));
|
||||||
|
SimpleAsyncTaskExecutor decorated = bootStyleVirtualThreadExecutor();
|
||||||
|
decorated.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||||
|
ctx = SecurityContextHolder.createEmptyContext();
|
||||||
|
ctx.setAuthentication(auth("dave"));
|
||||||
|
SecurityContextHolder.setContext(ctx);
|
||||||
|
run("D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper", decorated);
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java
Normal file
92
src/main/java/com/ankurm/vt/Demo3StructuredConcurrency.java
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ankurm.vt;
|
||||||
|
|
||||||
|
// Explained in docs/03-structured-concurrency.md -- run via scripts/run-all.sh, output captured in docs/output/
|
||||||
|
|
||||||
|
import io.micrometer.context.ContextSnapshot;
|
||||||
|
import io.micrometer.context.ContextSnapshotFactory;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.StructuredTaskScope;
|
||||||
|
import java.util.concurrent.StructuredTaskScope.Subtask;
|
||||||
|
|
||||||
|
/** Does a StructuredTaskScope subtask (a fresh virtual thread) see the parent's
|
||||||
|
* SecurityContext? Four scenarios, same question each time. Requires
|
||||||
|
* --enable-preview on JDK 25 (StructuredTaskScope is JEP 505, fifth preview). */
|
||||||
|
public class Demo3StructuredConcurrency {
|
||||||
|
|
||||||
|
static Authentication auth(String name) {
|
||||||
|
return UsernamePasswordAuthenticationToken.authenticated(
|
||||||
|
name, "n/a", AuthorityUtils.createAuthorityList("ROLE_USER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setAuth(String name) {
|
||||||
|
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
|
||||||
|
ctx.setAuthentication(auth(name));
|
||||||
|
SecurityContextHolder.setContext(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String readAuthInSubtask() {
|
||||||
|
Authentication a = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return a == null ? "NO AUTHENTICATION (lost)" : "authenticated as " + a.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.out.println("=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===");
|
||||||
|
|
||||||
|
// A) default MODE_THREADLOCAL, plain fork
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
|
||||||
|
setAuth("erin");
|
||||||
|
try (var scope = StructuredTaskScope.open()) {
|
||||||
|
Subtask<String> s = scope.fork(() -> "A) plain fork, MODE_THREADLOCAL: " + readAuthInSubtask());
|
||||||
|
scope.join();
|
||||||
|
System.out.println(s.get());
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
|
// B) MODE_INHERITABLETHREADLOCAL, plain fork
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
||||||
|
setAuth("frank");
|
||||||
|
try (var scope = StructuredTaskScope.open()) {
|
||||||
|
Subtask<String> s = scope.fork(() -> "B) plain fork, MODE_INHERITABLETHREADLOCAL: " + readAuthInSubtask());
|
||||||
|
scope.join();
|
||||||
|
System.out.println(s.get());
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // reset
|
||||||
|
|
||||||
|
// C) manual capture-and-restore around the forked Callable (portable, no reliance on mode)
|
||||||
|
setAuth("grace");
|
||||||
|
SecurityContext captured = SecurityContextHolder.getContext();
|
||||||
|
try (var scope = StructuredTaskScope.open()) {
|
||||||
|
Callable<String> task = () -> {
|
||||||
|
SecurityContextHolder.setContext(captured);
|
||||||
|
try {
|
||||||
|
return "C) manual capture/restore: " + readAuthInSubtask();
|
||||||
|
} finally {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Subtask<String> s = scope.fork(task);
|
||||||
|
scope.join();
|
||||||
|
System.out.println(s.get());
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
|
// D) Micrometer ContextSnapshot wrap (uses SecurityContextHolderThreadLocalAccessor)
|
||||||
|
setAuth("heidi");
|
||||||
|
ContextSnapshot snapshot = ContextSnapshotFactory.builder().build().captureAll();
|
||||||
|
try (var scope = StructuredTaskScope.open()) {
|
||||||
|
Callable<String> task = snapshot.wrap(
|
||||||
|
(Callable<String>) () -> "D) ContextSnapshot.wrap: " + readAuthInSubtask());
|
||||||
|
Subtask<String> s = scope.fork(task);
|
||||||
|
scope.join();
|
||||||
|
System.out.println(s.get());
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user