feat(core): expose bound ports, service override, FlashApplication and close hooks

Four small seams, each useful on its own, that together make a Flash app
testable without hand-rolled scaffolding.

- ServerHandle/ServerLifecycle/FlashApp gain port()/ports(). Listeners already
  bind in the FlashApp constructor, so port(0) resolved to a real port that
  nothing could read back; every integration test worked around this by
  opening a ServerSocket(0), closing it and reusing the number, which races
  anything else on the machine.

- FlashContext.override() replaces a binding instead of rejecting it. The
  duplicate-is-an-error rule stays everywhere else; this is the single
  deliberate exception, for swapping a service out in tests. A replacement is
  logged at INFO so misuse in production is visible.

- FlashApplication + FlashApp.apply() name an application independently of the
  port it runs on, so the same one can be booted twice. It takes FlashApp
  rather than FlashRegistrar because ws() and mount() live there. Being a
  functional interface, a lambda and a named class are the same thing.

- FlashContext.onClose() runs cleanup at stop(), children first and then in
  reverse registration order. stop() previously closed sockets and the
  executor and never touched the service graph, so a pooled DataSource was
  only ever released by JVM exit — invisible with one app per process, a leak
  per test class once a suite boots and stops many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-09 10:22:23 +00:00
co-authored by Claude Opus 5
parent 74169e40f4
commit 4a85a27648
6 changed files with 312 additions and 1 deletions
@@ -7,6 +7,7 @@ import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.transport.TransportFactory;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
@@ -28,6 +29,16 @@ public interface ServerHandle {
/** Gracefully stops the server, draining active connections. */
CompletableFuture<Void> stop();
/**
* Port of the first bound listener. Valid as soon as the handle exists — listeners are
* bound at construction, not at {@link #start()} — so configuring with port {@code 0}
* and reading the OS-assigned port back is a supported pattern.
*/
int port();
/** Ports of every bound listener, in configuration order. */
List<Integer> ports();
static ServerHandle create(FlashConfiguration config,
AbstractRouter httpRouter,
AbstractWsRouter wsRouter) throws IOException {
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -138,6 +139,16 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
return this;
}
/**
* Applies a {@link FlashApplication} to this app. Runs immediately, so {@code configure}
* sees a context that is still open for declarations and a listener that is already
* bound ({@link #port()}).
*/
public FlashApp apply(FlashApplication application) {
Objects.requireNonNull(application, "application").configure(this);
return this;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
/**
@@ -166,7 +177,24 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
server.startAndBlock();
}
public CompletableFuture<Void> stop() { return server.stop(); }
/**
* Gracefully stops the server, then runs every {@link FlashContext#onClose} callback so
* services release what they hold. Draining first means in-flight requests still see a
* live service graph.
*/
public CompletableFuture<Void> stop() {
return server.stop().whenComplete((ignored, failure) -> ctx.runCloseCallbacks());
}
/**
* Port of the first bound listener. Usable before {@link #start()}: listeners bind when
* the app is created, so {@code FlashApp.create(cfg with port 0).port()} yields the
* OS-assigned port, and an app can even be configured against its own address.
*/
public int port() { return server.port(); }
/** Ports of every bound listener, in configuration order. */
public List<Integer> ports() { return server.ports(); }
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@@ -0,0 +1,41 @@
package dev.relism.flash.extension;
/**
* One application's complete contribution to a {@link FlashApp} — its routes, extensions and
* services — expressed independently of which port or configuration it runs on.
*
* <p>Optional. The fluent form keeps working exactly as before; this interface exists so the
* same application can be created more than once, on more than one port:
*
* <pre>{@code
* public final class BlogApp implements FlashApplication {
* @Override public void configure(FlashApp app) {
* app.install(new JacksonExtension());
* app.mount("/api", scope -> scope.scan("dev.blog.api"));
* app.ws("/live", new FeedSocket());
* }
* }
*
* // production
* FlashApp.create(8080).apply(new BlogApp()).startAndBlock();
* }</pre>
*
* <p>It takes {@link FlashApp} rather than {@link FlashRegistrar} deliberately: {@code ws} and
* {@code mount} live on {@code FlashApp}, and an application that could not register a
* WebSocket route or a mounted namespace would be a half-application.
*
* <p>Being a {@code @FunctionalInterface}, a lambda and a named class are the same thing here —
* {@code app -> app.get("/ping", ...)} is as valid as {@code new BlogApp()}.
*
* @see FlashApp#apply(FlashApplication)
*/
@FunctionalInterface
public interface FlashApplication {
/**
* Registers this application onto {@code app}. Called immediately by
* {@link FlashApp#apply}, so the service graph is still open for declarations
* ({@code app.ctx().supply(...)}) and the listener is already bound ({@code app.port()}).
*/
void configure(FlashApp app);
}
@@ -1,10 +1,13 @@
package dev.relism.flash.extension;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/** Deterministic boot-time service graph, frozen before handlers are initialised. */
@Slf4j
public final class FlashContext {
private enum State { DECLARING, RESOLVING, READY }
@@ -13,6 +16,7 @@ public final class FlashContext {
private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>();
private final List<Runnable> readyCallbacks = new ArrayList<>();
private final List<Runnable> closeCallbacks = new ArrayList<>();
private final List<FlashContext> children = new ArrayList<>();
private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
private State state = State.DECLARING;
@@ -34,6 +38,23 @@ public final class FlashContext {
declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
}
/**
* Replaces the binding for {@code type}, whether or not one already exists.
*
* <p>The declaration rule everywhere else is that a duplicate is an error
* ({@link #provide}, {@link #supply}); this is the single deliberate exception, and it
* exists for tests — {@code flash-testing} installs overrides as the last extension so a
* fake wins over whatever the application or an extension declared. Using it in
* production code is legal but almost always a mistake, so a replacement is logged.
*/
public <T> void override(Class<T> type, T instance) {
requireDeclaring();
Objects.requireNonNull(type, "type");
Objects.requireNonNull(instance, "instance");
if (bindings.put(type, new Binding<>(type, List.of(), ignored -> instance)) != null)
log.info("Service binding overridden: {}", type.getName());
}
/** Declares a no-dependency boot factory. */
public <T> void supply(Class<T> type, Supplier<T> factory) {
declare(type, new Binding<>(type, List.of(), ignored -> factory.get()));
@@ -53,6 +74,21 @@ public final class FlashContext {
/** Registers work materialised after all services are resolved. */
public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); }
/**
* Registers cleanup to run when the app stops, after in-flight requests have drained.
*
* <p>Deliberately callable outside {@code DECLARING} so a factory can register its own
* teardown while it is being resolved:
* <pre>{@code
* ctx.supply(DataSource.class, c -> {
* HikariDataSource ds = new HikariDataSource(cfg);
* c.onClose(ds::close);
* return ds;
* });
* }</pre>
*/
public void onClose(Runnable callback) { closeCallbacks.add(Objects.requireNonNull(callback)); }
@SuppressWarnings("unchecked")
public <T> T require(Class<T> type) {
if (state == State.DECLARING)
@@ -106,6 +142,21 @@ public final class FlashContext {
for (FlashContext child : children) child.runReadyCallbacks();
}
/**
* Runs every {@link #onClose} callback once: children first, then this context's own in
* reverse registration order, so a service always closes before whatever it depends on.
* A throwing callback is logged and does not stop the rest. Clearing makes a second
* {@code stop()} a no-op.
*/
void runCloseCallbacks() {
for (FlashContext child : children) child.runCloseCallbacks();
for (int i = closeCallbacks.size() - 1; i >= 0; i--) {
try { closeCallbacks.get(i).run(); }
catch (RuntimeException e) { log.error("Close callback failed", e); }
}
closeCallbacks.clear();
}
/** Completes graph resolution and runs all deferred materialisation callbacks once. */
public void complete() {
resolveAll();
@@ -46,6 +46,14 @@ public final class ServerLifecycle implements ServerHandle {
this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size());
}
@Override
public int port() { return listeners.get(0).socket().getLocalPort(); }
@Override
public List<Integer> ports() {
return listeners.stream().map(bound -> bound.socket().getLocalPort()).toList();
}
/** Whether the server has begun shutting down. Passed down to every connection as a
* {@link java.util.function.BooleanSupplier} so in-flight request loops can drain
* promptly instead of waiting for their next keep-alive request. */
@@ -0,0 +1,172 @@
package dev.relism.flash.extension;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* The four seams {@code flash-testing} is built on: reading back an ephemeral port,
* replacing a binding, applying an application, and closing services at stop.
*/
class FlashAppLifecycleTest {
private static FlashConfiguration ephemeral() {
return FlashConfiguration.builder().port(0).host("127.0.0.1")
.shutdownDrainTimeoutMs(250).build();
}
// ── port() ───────────────────────────────────────────────────────────────
@Test
void exposesTheOsAssignedPortBeforeStart() {
FlashApp app = FlashApp.create(ephemeral());
try {
assertTrue(app.port() > 0, "port 0 should resolve to a real bound port");
assertEquals(List.of(app.port()), app.ports());
} finally {
app.stop().join();
}
}
@Test
void keepsTheSamePortAcrossStart() {
FlashApp app = FlashApp.create(ephemeral()).get("/ping", (req, res) -> "pong");
try {
int beforeStart = app.port();
app.start();
assertEquals(beforeStart, app.port());
} finally {
app.stop().join();
}
}
// ── override() ───────────────────────────────────────────────────────────
@Test
void overrideReplacesAnExistingBinding() {
FlashContext ctx = new FlashContext();
ctx.provide(Greeter.class, () -> "real");
ctx.override(Greeter.class, () -> "fake");
ctx.resolveAll();
assertEquals("fake", ctx.require(Greeter.class).greet());
}
@Test
void overrideAlsoDeclaresWhenNothingWasBound() {
FlashContext ctx = new FlashContext();
ctx.override(Greeter.class, () -> "fake");
ctx.resolveAll();
assertEquals("fake", ctx.require(Greeter.class).greet());
}
@Test
void overrideIsRejectedOnceDeclarationsAreClosed() {
FlashContext ctx = new FlashContext();
ctx.resolveAll();
assertThrows(IllegalStateException.class, () -> ctx.override(Greeter.class, () -> "fake"));
}
@Test
void lastInstalledExtensionWinsOverAnEarlierProvider() {
FlashApp app = FlashApp.create(ephemeral())
.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"))
.install((registrar, ctx) -> ctx.override(Greeter.class, () -> "fake"));
try {
app.start();
assertEquals("fake", app.ctx().require(Greeter.class).greet());
} finally {
app.stop().join();
}
}
// ── apply() ──────────────────────────────────────────────────────────────
@Test
void applyRunsImmediatelyWithAnOpenContextAndABoundPort() {
List<Integer> portSeenInsideConfigure = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
portSeenInsideConfigure.add(configured.port());
configured.ctx().provide(Greeter.class, () -> "from-application");
configured.get("/hello", (req, res) -> "hi");
});
try {
assertEquals(List.of(app.port()), portSeenInsideConfigure);
app.start();
assertEquals("from-application", app.ctx().require(Greeter.class).greet());
} finally {
app.stop().join();
}
}
// ── onClose() ────────────────────────────────────────────────────────────
@Test
void stopRunsCloseCallbacksInReverseOrder() {
List<String> closed = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
configured.ctx().onClose(() -> closed.add("first"));
configured.ctx().onClose(() -> closed.add("second"));
});
app.start();
app.stop().join();
assertEquals(List.of("second", "first"), closed);
}
@Test
void aFactoryCanRegisterItsOwnTeardownWhileResolving() {
AtomicInteger closes = new AtomicInteger();
FlashApp app = FlashApp.create(ephemeral()).apply(configured ->
configured.ctx().supply(Greeter.class, services -> {
services.onClose(closes::incrementAndGet);
return () -> "resolved";
}));
app.start();
assertEquals(0, closes.get(), "must not close while the app is live");
app.stop().join();
assertEquals(1, closes.get());
}
@Test
void aThrowingCloseCallbackDoesNotStopTheRest() {
List<String> closed = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
configured.ctx().onClose(() -> closed.add("ran"));
configured.ctx().onClose(() -> { throw new IllegalStateException("boom"); });
});
app.start();
assertDoesNotThrow(() -> app.stop().join());
assertEquals(List.of("ran"), closed);
}
@Test
void closeCallbacksRunAtMostOnce() {
AtomicInteger closes = new AtomicInteger();
FlashApp app = FlashApp.create(ephemeral())
.apply(configured -> configured.ctx().onClose(closes::incrementAndGet));
app.start();
app.stop().join();
app.stop().join();
assertEquals(1, closes.get());
}
@FunctionalInterface
interface Greeter { String greet(); }
}