diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
index 37fa27e..712894d 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java
@@ -131,6 +131,27 @@ public final class Fleetd {
}
static void main(String[] args) {
+ main(args, ResourcePorts.system());
+ }
+
+ /**
+ * fleetd #625: package-private so a test can drive the literal startup sequence — not a copy
+ * of it — against a non-production {@link ResourcePorts} whose {@link
+ * ResourcePorts#environment()} is tainted, without ever touching the real process environment.
+ * The real process environment is exactly what a test cannot taint from inside the JVM, which
+ * is why nothing could pin {@link SubscriptionGuard#assertPrimaryClean} running at this call
+ * site before this ticket.
+ *
+ *
{@link #main(String[])} is the one production caller, passing {@link
+ * ResourcePorts#system()}. Every statement below, in the same order, is otherwise unchanged
+ * from before this ticket — in particular the guard still runs here, before {@code
+ * cfg.validateAll()} and before {@link FleetdAssembly#assembleAndStart} ever touches a socket,
+ * a broker, or HTTP, exactly as it always has. {@code ports} is reused for the guard check and
+ * then handed on to the assembly, rather than a second instance being constructed there, so a
+ * test's fake backs the whole boot path with one consistent view — see {@code
+ * FleetdSubscriptionGuardOrderingTest}.
+ */
+ static void main(String[] args, ResourcePorts ports) {
Path configPath = args.length > 0 ? Path.of(args[0]) : chooseDefaultConfigFile(Path.of(""));
// The config file was renamed bridged.yaml -> fleetd.yaml. Name the file we actually
// loaded, whichever of the two names it carries.
@@ -166,7 +187,7 @@ public final class Fleetd {
// The primary/host env that launched fleetd must not be tainted.
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
- guard.assertPrimaryClean(System.getenv());
+ guard.assertPrimaryClean(ports.environment());
// Every FleetConfig.validateXxx() the operator's config can fail — CB-501's auth-exposure
// check, CB-531's lead-tab-prefix check, CB-542's subscription-profile check, the charter
@@ -189,7 +210,9 @@ public final class Fleetd {
// after validateAll() (this line) puts both back under test, in the same relative order,
// before either one does any I/O — see FleetdAssembly's javadoc for the full boot-order
// contract this preserves exactly.
- FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ResourcePorts.system());
+ // fleetd #625: the same `ports` the guard check above just used, not a second
+ // ResourcePorts.system() instance — see this method's own javadoc.
+ FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
}
/**
diff --git a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
index c51c3b4..52df9ca 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
@@ -194,7 +194,7 @@ final class FleetdAssembly {
// CB-504: under supervision (launchd/systemd) fleetd can start before herdr's socket
// exists. Wait, then degrade rather than die: serving with /healthz reporting "degraded" is
// strictly more useful than exiting.
- Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), Fleetd::sleepHerdrPoll);
+ Fleetd.HerdrAwaitOutcome herdrOutcome = Fleetd.awaitHerdr(herdr, ports.nanoClock(), ports.herdrPollWait());
boolean herdrUp = Fleetd.logHerdrWaitOutcomeAndShouldReap(herdrOutcome);
if (herdrUp) {
// CB-117: herdr keeps worker panes alive across a daemon restart, and their ids died
diff --git a/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java b/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java
index bec9d42..5fd594a 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/ResourcePorts.java
@@ -43,6 +43,19 @@ public interface ResourcePorts {
/** A monotonic elapsed-time clock. Production: {@link System#nanoTime()}. */
LongSupplier nanoClock();
+ /**
+ * fleetd #629: the per-poll wait {@code FleetdAssembly#assembleAndStart} passes to {@code
+ * Fleetd#awaitHerdr} while polling for herdr's socket. Production: {@link
+ * Fleetd#sleepHerdrPoll()} — a real {@code Thread.sleep}. {@link #nanoClock()} alone is not
+ * enough to make {@code awaitHerdr}'s deadline controllable: the old call site passed {@code
+ * Fleetd::sleepHerdrPoll} directly, hardcoded, so a test that injected a fake clock still had
+ * to wait out the real sleep between each poll to ever reach the deadline — the clock looked
+ * injected and was not actually controllable. A test supplies a no-op that advances its own
+ * injected {@link #nanoClock()} instead, so the deadline becomes reachable without any real
+ * wall-clock time passing.
+ */
+ Runnable herdrPollWait();
+
/**
* A wall-clock reading, in nanoseconds. Production: {@code System.currentTimeMillis()}
* converted to nanoseconds. Kept separate from {@link #nanoClock()} because {@link
diff --git a/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java b/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java
index 7ce5ddd..ed02a34 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/SystemResourcePorts.java
@@ -44,6 +44,11 @@ final class SystemResourcePorts implements ResourcePorts {
return System::nanoTime;
}
+ @Override
+ public Runnable herdrPollWait() {
+ return Fleetd::sleepHerdrPoll;
+ }
+
@Override
public LongSupplier wallClockNanos() {
return () -> TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis());
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAmqpOpenersTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAmqpOpenersTest.java
index b5060f7..00be5a1 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAmqpOpenersTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAmqpOpenersTest.java
@@ -91,6 +91,13 @@ class FleetdAssemblyAmqpOpenersTest {
};
}
@Override public LongSupplier nanoClock() { return System::nanoTime; }
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
@Override public LongSupplier wallClockNanos() { return System::nanoTime; }
@Override public ScheduledExecutorService newScheduler(String purpose) {
return Executors.newSingleThreadScheduledExecutor();
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java
index df5afdf..67d41f5 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java
@@ -117,6 +117,14 @@ class FleetdAssemblyConnectionIdentityTest {
public void startHttp(Javalin app, String host, int port) {
// Deliberately never bind — this test never issues a real HTTP request.
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private FleetdRuntime runtime;
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java
index d5262b0..627a431 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java
@@ -140,6 +140,14 @@ class FleetdAssemblyCoordinatorLifecycleTest {
public void startHttp(Javalin app, String host, int port) {
// No real HTTP bind in a unit test.
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static final class SentinelReplyInbox implements ReplyInbox {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java
index ba2b8c3..52e49ba 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java
@@ -8,6 +8,7 @@ import dev.ltms.fleet.herdr.HerdrClient;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
@@ -21,6 +22,8 @@ import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -69,13 +72,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
* below is what this class relies on for CB-185's {@code FleetApp} half; {@code
* FleetAppTwoDaemonTest} remains the full behavioural proof that {@code FleetApp} itself merges
* {@code /sessions} correctly once handed two clients.
+ *
+ *
fleetd #629 follow-up. The fix below (see {@link TwoHerdrResourcePorts})
+ * makes {@link #healthzGoesRedWhenTheLeadDaemonIsDownEvenThoughTheMemberIsUp}'s fake {@code
+ * nanoClock()} frozen unless {@code herdrPollWait()} itself advances it. That is a sharper pin
+ * than an assertion — if a future edit to {@code FleetdAssembly} ever bypasses {@code
+ * ports.herdrPollWait()} again (e.g. reverting to a hardcoded {@code Thread.sleep}), the clock
+ * never advances, {@code Fleetd#awaitHerdr}'s deadline is never reached, and this test hangs
+ * forever instead of failing — proven by deliberately reintroducing that exact regression while
+ * fixing this ticket. {@code @Timeout} turns that silent hang into a bounded, named test failure:
+ * {@code SEPARATE_THREAD} so JUnit's timeout governor can actually interrupt a thread stuck in a
+ * real {@code Thread.sleep} loop (the default {@code SAME_THREAD} mode cannot — it only measures
+ * elapsed time after the test method returns on its own, which never happens here). 10 seconds is
+ * roughly 150x the real passing times measured here (~0.06s), so a slow CI machine has no reason
+ * to flake, and it is still 3x faster than discovering the regression by burning a CI job's whole
+ * wall-clock budget.
*/
+@Timeout(value = 10, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
class FleetdAssemblyFleetAppTest {
private static final class TwoHerdrResourcePorts implements ResourcePorts {
final Map herdrsBySocket = new LinkedHashMap<>();
final CopyOnWriteArrayList schedulers = new CopyOnWriteArrayList<>();
+ // fleetd #629: a fake, advanceable clock — NOT System::nanoTime. awaitHerdr's poll wait
+ // (herdrPollWait() below) advances this on every poll instead of sleeping for real, so the
+ // down-lead test below reaches awaitHerdr's deadline without burning real wall-clock time.
+ final AtomicLong nowNanos = new AtomicLong(1_000_000_000L); // arbitrary non-zero start
Runnable shutdownHook;
@Override
@@ -107,7 +130,7 @@ class FleetdAssemblyFleetAppTest {
@Override
public LongSupplier nanoClock() {
- return System::nanoTime;
+ return nowNanos::get;
}
@Override
@@ -115,6 +138,13 @@ class FleetdAssemblyFleetAppTest {
return System::nanoTime;
}
+ @Override
+ public Runnable herdrPollWait() {
+ // fleetd #629: advance the fake clock instead of a real Thread.sleep, so awaitHerdr's
+ // deadline is reached in real time regardless of the configured poll interval.
+ return () -> nowNanos.addAndGet(TimeUnit.SECONDS.toNanos(1));
+ }
+
@Override
public ScheduledExecutorService newScheduler(String purpose) {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java
index 8f8f488..e4c32e2 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyLifecycleTest.java
@@ -126,6 +126,14 @@ class FleetdAssemblyLifecycleTest {
ledger.add("startHttp");
this.startedApp = app;
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
/** A fake {@link ReplyInbox} that is also {@link AutoCloseable}, so the ledger can prove it closes. */
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java
index b4d03c4..daf45ca 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyRoleFallbackBoundaryTest.java
@@ -98,6 +98,14 @@ class FleetdAssemblyRoleFallbackBoundaryTest {
public void startHttp(Javalin app, String host, int port) {
// No real HTTP bind in a unit test.
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static final class SentinelReplyInbox implements ReplyInbox {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java
index 2852739..c7d90c1 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java
@@ -100,6 +100,14 @@ class FleetdBackendQuarantineAssemblyTest {
@Override
public void startHttp(Javalin app, String host, int port) {
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java
index 664af23..df3091a 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java
@@ -126,6 +126,14 @@ class FleetdCompletionResolverAssemblyTest {
public void startHttp(Javalin app, String host, int port) {
// Deliberately never bind a real port.
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static FleetConfig writeConfig(Path dir, String profilesYaml, String extraGuardHost,
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustedPatternAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustedPatternAssemblyTest.java
index ee37adb..ba6ae97 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustedPatternAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustedPatternAssemblyTest.java
@@ -95,6 +95,13 @@ class FleetdExhaustedPatternAssemblyTest {
};
}
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
@Override
public LongSupplier nanoClock() {
return nowNanos::get;
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
index d5d0e25..70f13d5 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
@@ -115,6 +115,14 @@ class FleetdLeadRolloverAssemblyTest {
@Override
public void startHttp(Javalin app, String host, int port) {
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java
index aff5704..6457a37 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java
@@ -90,6 +90,14 @@ class FleetdLeadSeatAssemblyTest {
@Override
public void startHttp(Javalin app, String host, int port) {
}
+
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
}
private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdOpenCodeExhaustionForwardingAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdOpenCodeExhaustionForwardingAssemblyTest.java
index 9412fdb..8ef348d 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdOpenCodeExhaustionForwardingAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdOpenCodeExhaustionForwardingAssemblyTest.java
@@ -87,6 +87,13 @@ class FleetdOpenCodeExhaustionForwardingAssemblyTest {
};
}
+ @Override
+ public Runnable herdrPollWait() {
+ // Never invoked: this test's FakeHerdr answers immediately, so awaitHerdr never polls.
+ return () -> {
+ throw new UnsupportedOperationException("herdrPollWait must not be called — herdr is healthy");
+ };
+ }
@Override
public LongSupplier nanoClock() {
return nowNanos::get;
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java
new file mode 100644
index 0000000..2bf3003
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdSubscriptionGuardOrderingTest.java
@@ -0,0 +1,202 @@
+package dev.ltms.fleet;
+
+import dev.ltms.fleet.guard.GuardException;
+import dev.ltms.fleet.herdr.HerdrClient;
+import io.javalin.Javalin;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.LongSupplier;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * fleetd #625: pins {@link dev.ltms.fleet.guard.SubscriptionGuard#assertPrimaryClean}'s call site
+ * in {@link Fleetd#main(String[])} — the ONE place it runs at startup, and the check behind the
+ * bridge charter's invariant 1 (never let the primary carry {@code ANTHROPIC_BASE_URL}). Nothing
+ * pinned it before this ticket: deleting {@code guard.assertPrimaryClean(...)} from {@code main}
+ * left the full suite green, because the call site read the real process environment ({@code
+ * System.getenv()}), which a test cannot taint from inside the JVM.
+ *
+ * {@link Fleetd#main(String[], ResourcePorts)} (added by this ticket) is the literal production
+ * sequence — not a copy of it — driven here with a {@link ResourcePorts} whose {@link
+ * ResourcePorts#environment()} is a plain {@code Map} a test controls. The guard itself was
+ * already pinned by {@code SubscriptionGuardTest}, directly, with a {@code Map} — that proves the
+ * method's behaviour, not that {@code main} still calls it at the right point. This class pins the
+ * call site and, separately, the ORDER: the guard must still run before {@code cfg.validateAll()}
+ * and before {@code FleetdAssembly.assembleAndStart} touches a socket, a broker, or HTTP — not just
+ * be present somewhere in {@code main}.
+ *
+ *
Presence alone is not enough (a fix that pins only presence trades an invisible deletion for
+ * an invisible reordering), so each test below is built so that EITHER deleting the guard call OR
+ * moving it later makes the same test fail — with a different exception type than the one
+ * asserted, not a vacuous pass. See each test's own javadoc for how.
+ */
+class FleetdSubscriptionGuardOrderingTest {
+
+ private static final Map TAINTED_ENV =
+ Map.of("ANTHROPIC_BASE_URL", "http://tainted.example");
+ private static final Map CLEAN_ENV = Map.of("PATH", "/usr/bin");
+
+ /**
+ * A {@link ResourcePorts} whose {@link #environment()} is fixed to whatever the test hands it,
+ * and whose every other method refuses to be called at all. That refusal is the ordering pin:
+ * if {@code main} ever reaches {@link FleetdAssembly#assembleAndStart} before the guard has had
+ * a chance to throw, the very first thing the assembly does with {@code ports} is {@link
+ * #connectHerdr} — so a test that expects {@link GuardException} and instead observes {@link
+ * UnsupportedOperationException} has just caught the guard running too late (or not at all).
+ */
+ private static final class FixedEnvPorts implements ResourcePorts {
+ private final Map env;
+
+ FixedEnvPorts(Map env) {
+ this.env = env;
+ }
+
+ @Override
+ public Map environment() {
+ return env;
+ }
+
+ @Override
+ public HerdrClient connectHerdr(Path socketPath) {
+ throw new UnsupportedOperationException("connectHerdr must not be called before the guard runs");
+ }
+
+ @Override
+ public Fleetd.AmqpOpener replyInboxOpener() {
+ throw new UnsupportedOperationException("replyInboxOpener must not be called before the guard runs");
+ }
+
+ @Override
+ public Fleetd.LeadMailboxOpener leadMailboxOpener() {
+ throw new UnsupportedOperationException("leadMailboxOpener must not be called before the guard runs");
+ }
+
+ @Override
+ public LongSupplier nanoClock() {
+ throw new UnsupportedOperationException("nanoClock must not be called before the guard runs");
+ }
+
+ @Override
+ public LongSupplier wallClockNanos() {
+ throw new UnsupportedOperationException("wallClockNanos must not be called before the guard runs");
+ }
+
+ @Override
+ public ScheduledExecutorService newScheduler(String purpose) {
+ throw new UnsupportedOperationException("newScheduler must not be called before the guard runs");
+ }
+
+ @Override
+ public void addShutdownHook(Runnable hook) {
+ throw new UnsupportedOperationException("addShutdownHook must not be called before the guard runs");
+ }
+
+ @Override
+ public void startHttp(Javalin app, String host, int port) {
+ throw new UnsupportedOperationException("startHttp must not be called before the guard runs");
+ }
+
+ @Override
+ public Runnable herdrPollWait() {
+ throw new UnsupportedOperationException("herdrPollWait must not be called before the guard runs");
+ }
+ }
+
+ /**
+ * Otherwise-invalid: {@code bind.host: 0.0.0.0} with no {@code auth.mode: token} fails {@code
+ * cfg.validateAll()} (CB-501's auth-exposure check — the same fixture {@code
+ * FleetdStartupValidationTest#mainRefusesANonLoopbackBindWithoutTokenMode} uses), with an
+ * {@link IllegalStateException}. That is deliberate: it is what {@code main} would throw INSTEAD
+ * of {@link GuardException} if the guard call were deleted, or moved to run after {@code
+ * validateAll()} — a different, distinguishable exception type.
+ */
+ private static Path invalidConfig(Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ host: 0.0.0.0
+ port: 8765
+ """);
+ return f;
+ }
+
+ /** Passes {@code cfg.validateAll()} cleanly — nothing here trips any of its checks. */
+ private static Path validConfig(Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ """);
+ return f;
+ }
+
+ /**
+ * Pins the order against {@code cfg.validateAll()}. The environment is tainted and the config
+ * is otherwise invalid (see {@link #invalidConfig}). If the guard runs first (the required
+ * order), {@code main} throws {@link GuardException} before {@code validateAll()} is ever
+ * reached. If the guard were deleted, or reordered to run after {@code validateAll()}, {@code
+ * validateAll()} throws {@link IllegalStateException} instead and this assertion fails on the
+ * wrong exception type.
+ */
+ @Test
+ void mainRefusesATaintedEnvironmentBeforeValidatingTheConfig(@TempDir Path dir) throws Exception {
+ Path config = invalidConfig(dir);
+ FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV);
+
+ GuardException ex = assertThrows(GuardException.class,
+ () -> Fleetd.main(new String[]{config.toString()}, ports));
+ assertTrue(ex.getMessage().contains("tainted"),
+ "expected the primary-taint message, got: " + ex.getMessage());
+ }
+
+ /**
+ * Pins the order against {@code FleetdAssembly.assembleAndStart}. The environment is tainted
+ * and the config is otherwise VALID (see {@link #validConfig}), so {@code cfg.validateAll()}
+ * passes silently and the next thing that could possibly run is the assembly's first socket
+ * call. If the guard runs first (the required order), {@code main} throws {@link
+ * GuardException} before assembly starts. If the guard were deleted, or reordered to run after
+ * assembly begins touching {@code ports}, {@link FixedEnvPorts#connectHerdr} throws {@link
+ * UnsupportedOperationException} instead and this assertion fails on the wrong exception type.
+ */
+ @Test
+ void mainRefusesATaintedEnvironmentBeforeAssemblyTouchesAnyPort(@TempDir Path dir) throws Exception {
+ Path config = validConfig(dir);
+ FixedEnvPorts ports = new FixedEnvPorts(TAINTED_ENV);
+
+ GuardException ex = assertThrows(GuardException.class,
+ () -> Fleetd.main(new String[]{config.toString()}, ports));
+ assertTrue(ex.getMessage().contains("tainted"),
+ "expected the primary-taint message, got: " + ex.getMessage());
+ }
+
+ /**
+ * The CONTROL for the two tests above. Same otherwise-valid config, same {@link FixedEnvPorts}
+ * whose every method but {@code environment()} refuses to be called — but a CLEAN environment.
+ * Without this, a guard that always threw {@link GuardException} regardless of input (the
+ * opposite bug — e.g. the check inverted) would make the two tests above pass for the wrong
+ * reason: not because they actually drove a real taint through a real guard, but because
+ * anything would have thrown {@code GuardException}. Here, with nothing to taint, the guard
+ * must let {@code main} proceed into {@code cfg.validateAll()} and on into the real assembly,
+ * which reaches {@code ports.connectHerdr} — and THAT throws. A loud, positive assertion: if
+ * the boot path never actually ran this far, there is no {@link UnsupportedOperationException}
+ * to catch, only a quiet, unexpected hang or an unrelated early failure.
+ */
+ @Test
+ void mainProceedsPastTheGuardOnACleanEnvironment(@TempDir Path dir) throws Exception {
+ Path config = validConfig(dir);
+ FixedEnvPorts ports = new FixedEnvPorts(CLEAN_ENV);
+
+ UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class,
+ () -> Fleetd.main(new String[]{config.toString()}, ports));
+ assertTrue(ex.getMessage().contains("connectHerdr"),
+ "expected forward progress to reach the assembly's first port call, got: " + ex.getMessage());
+ }
+}