diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 5788a06..5cf29e2 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -110,6 +110,14 @@ bind: # notifications: # mode: disabled +# Idle-sleep guard: while at least one member is live, hold an OS-level assertion against idle +# sleep (macOS only — a `caffeinate -i` child; a no-op elsewhere or if caffeinate is missing), so +# an unattended host does not idle-sleep out from under a member's long turn. Unlike health/ +# configReload above, this is ON BY DEFAULT — omitting the block entirely leaves it enabled, the +# same as `enabled: true`. Uncomment only to turn it off: +# idleSleepGuard: +# enabled: false + # herdr Unix socket. Omit to use the client default # (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). herdrSocket: ~/.config/herdr/herdr.sock diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 6058535..6f64e3e 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -55,6 +55,8 @@ import dev.ltms.fleet.member.MemberCredentialPolicyView; import dev.ltms.fleet.member.OpenCodeLauncher; import dev.ltms.fleet.placement.BackendOutagePolicy; import dev.ltms.fleet.placement.BackendQuarantine; +import dev.ltms.fleet.power.CaffeinateSleepAssertionMechanism; +import dev.ltms.fleet.power.IdleSleepGuard; import io.javalin.Javalin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -252,6 +254,26 @@ public final class Fleetd { System::nanoTime, contextCap, clearAfterTurn); liveCountRef.set(profileName -> liveSessionCount(sessions.roster(), profileName)); + // Idle-sleep guard: hold an OS-level assertion against idle sleep while at least one + // member is live, so an unattended host does not idle-sleep out from under a member's + // long turn (see FleetConfig.IdleSleepGuard / dev.ltms.fleet.power.IdleSleepGuard for the + // measurement that motivated this). Opt-out via idleSleepGuard.enabled: false; on by + // default. Hangs off SessionManager's own onAcquire/onRelease hooks (CB-520/CB-516, + // previously wired only to the reply inbox) and SessionManager#size() — the exact registry + // fleet_list's live/capacity numbers are themselves computed from — rather than tracking + // members a second way. No-op (never constructed) off macOS or when idleSleepGuard.enabled + // is explicitly false; the mechanism itself is additionally a no-op if 'caffeinate' cannot + // be started, so this can never fail a spawn, a release, or startup. + boolean idleSleepGuardEnabled = cfg.idleSleepGuard() == null || cfg.idleSleepGuard().isEnabled(); + final IdleSleepGuard idleSleepGuard; + if (idleSleepGuardEnabled) { + idleSleepGuard = new IdleSleepGuard(new CaffeinateSleepAssertionMechanism(), sessions::size); + sessions.onAcquire(_ -> idleSleepGuard.recheck()); + sessions.onRelease(_ -> idleSleepGuard.recheck()); + } else { + idleSleepGuard = null; + } + // CB-303 part 1: idle-ttl reaper — only when configured, defaults to disabled. final SessionReaper reaper; if (cfg.lifecycle() != null @@ -698,6 +720,11 @@ public final class Fleetd { if (configWatcher != null) configWatcher.stop(); // CB-559: stop polling the config file mcp.close(); if (reaper != null) reaper.stop(); + // Idle-sleep guard: release unconditionally, even though sessions.close() above already + // drained every session (and each release already drove the live count to 0, which + // releases the guard's assertion on its own) — this is the backstop for a drain that was + // itself interrupted or threw, so no caffeinate child ever outlives the daemon. + if (idleSleepGuard != null) idleSleepGuard.close(); // Release the broker connection last among message resources (no-op for the in-memory inbox). if (replyInbox instanceof AutoCloseable closeable) { try { diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java index 1c37bd1..6af7452 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -37,6 +37,10 @@ import java.util.function.Supplier; * makes {@code fleet:} split rather than hot — see below. *
  • Deferred — accepted into the new snapshot, but the wiring built at startup * keeps the old value until a restart: {@code lifecycle:}, {@code leadHeartbeat:}, + * {@code idleSleepGuard:} ({@code Fleetd.java} reads it once, at startup, to decide whether + * to construct an {@code IdleSleepGuard} and wire {@code SessionManager}'s + * {@code onAcquire}/{@code onRelease} hooks to it — neither is rebuilt on reload, so a + * running daemon keeps whatever this was at startup regardless of a later edit), * {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code quarantineCooldownSeconds} * (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup), * {@code guard:}, {@code worktreeRoot:} and {@code worktreeGroup:} (both baked once into the @@ -129,8 +133,9 @@ import java.util.function.Supplier; * five of COLD_KEYS" rather than re-listing them, so prose and set cannot drift again.
  • * * - *

    The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333). - * {@code FleetConfig} has 22 top-level record components: 5 cold, 11 deferred, 3 split, 3 + *

    The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333), + * recounted again after {@code idleSleepGuard:} was added. + * {@code FleetConfig} has 23 top-level record components: 5 cold, 12 deferred, 3 split, 3 * hot-excluded. Three of them are named nowhere in this file, and the reason is the same for all * three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are * hot and correctly absent — all three are read live off {@code config.get()} @@ -213,7 +218,7 @@ public final class ConfigRef implements Supplier { static final Set DEFERRED_KEYS = Set.of( "guard", "worktreeRoot", "worktreeGroup", "primary", "configReload", "leadHeartbeat", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", - "quarantineCooldownSeconds", "profiles"); + "quarantineCooldownSeconds", "profiles", "idleSleepGuard"); private final Path path; private final AtomicReference current; @@ -418,6 +423,14 @@ public final class ConfigRef implements Supplier { if (!Objects.equals(old.configReload(), fresh.configReload())) { changed.add("configReload"); } + // Fleetd.java reads cfg.idleSleepGuard() once, at startup, to decide whether to construct + // an IdleSleepGuard at all and wire SessionManager's onAcquire/onRelease hooks to it — + // neither is rebuilt on reload, so a running daemon keeps whatever this was at startup + // (armed or not) regardless of a later edit here. Not cold: nothing already-open goes + // inconsistent with the new value, an armed-or-not guard just keeps its original answer. + if (!Objects.equals(old.idleSleepGuard(), fresh.idleSleepGuard())) { + changed.add("idleSleepGuard"); + } if (!Objects.equals(old.spawnReadyTimeoutMs(), fresh.spawnReadyTimeoutMs()) || !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) { changed.add("spawnReady*"); diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index ef96eb1..14768c0 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -106,6 +106,11 @@ import java.util.regex.PatternSyntaxException; * When {@code memberHerdrSocket} is NOT configured this field is never * consulted at all; fleetd keeps reading its own {@code $SHELL}, exactly as * before this field existed. + * @param idleSleepGuard opt-in-by-default: hold an OS-level assertion against idle sleep while at + * least one member is live, so an unattended host does not sleep out from + * under a member's long turn. {@code null} (the block omitted) behaves the + * same as an explicit {@code enabled: true}; set {@code enabled: false} to + * turn it off. See {@link dev.ltms.fleet.power.IdleSleepGuard}. */ @JsonIgnoreProperties(ignoreUnknown = true) public record FleetConfig( @@ -130,7 +135,22 @@ public record FleetConfig( MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup, - String memberLoginShell) { + String memberLoginShell, + IdleSleepGuard idleSleepGuard) { + + /** Back-compat form before the {@code idleSleepGuard:} block was added. */ + public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map profiles, + Guard guard, String worktreeRoot, Lifecycle lifecycle, Integer spawnReadyTimeoutMs, + Integer spawnReadyPollMs, Broker broker, Primary primary, Fleet fleet, + LeadHeartbeat leadHeartbeat, Health health, String placement, Auth auth, + ConfigReload configReload, Integer quarantineCooldownSeconds, + MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup, + String memberLoginShell) { + this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs, + spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth, + configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup, + memberLoginShell, null); + } /** Back-compat form before the {@code memberLoginShell} key was added. */ public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map profiles, @@ -141,7 +161,7 @@ public record FleetConfig( MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup) { this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs, spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth, - configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup, null); + configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup, null, null); } /** Back-compat form before the {@code worktreeGroup} key was added. */ @@ -1245,6 +1265,25 @@ public record FleetConfig( } } + /** + * Hold an OS-level assertion against idle sleep while at least one member is live (see + * {@link dev.ltms.fleet.power.IdleSleepGuard}). + * + *

    Unlike most opt-in blocks in this file, this one defaults to on: an unattended + * host idle-sleeping mid-turn is a correctness problem (a dropped AMQP link, a frozen member), + * not a convenience, so the safer default is armed. An operator who wants the previous + * behaviour (no assertion held, ever) sets {@code enabled: false} explicitly. + * + * @param enabled {@code false} turns the guard off; {@code null} (the block omitted + * entirely) or {@code true} leaves it on + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record IdleSleepGuard(Boolean enabled) { + public boolean isEnabled() { + return !Boolean.FALSE.equals(enabled); + } + } + /** * The terminal → lead-name map seeded from the legacy singular {@code primary:} pin (CB-530). * @@ -1495,7 +1534,7 @@ public record FleetConfig( "bind", "herdrSocket", "memberHerdrSocket", "profiles", "guard", "worktreeRoot", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet", "leadHeartbeat", "health", "placement", "auth", "configReload", "quarantineCooldownSeconds", - "memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell"); + "memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "idleSleepGuard"); /** Load and validate config from {@code path}. */ public static FleetConfig load(Path path) { @@ -2172,9 +2211,14 @@ public record FleetConfig( // memberLoginShell is left as-is (fleetd #213), like worktreeGroup: null/blank is "not // configured", and there is no sane non-null default — a member's login shell is // operator-specific and only meaningful when memberHerdrSocket is also set. + // idleSleepGuard is left as-is, like leadHeartbeat/configReload above, but for the opposite + // reason: it is on by default already (its own isEnabled() treats null the same as + // enabled: true — see its javadoc), so defaulting the block here would change nothing a + // reader observes and would only obscure that "block omitted" and "block present and + // enabled" are deliberately the same outcome. return new FleetConfig(b, herdrSocket, memberHerdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs, broker, primary, f, leadHeartbeat, health, placementOrDefault, a, configReload, - quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell); + quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, idleSleepGuard); } /** diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java b/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java new file mode 100644 index 0000000..0c45b57 --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java @@ -0,0 +1,93 @@ +package dev.ltms.fleet.power; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Holds macOS idle sleep off by keeping a {@code caffeinate -i} child process alive for the life + * of the returned {@link SleepAssertion}. + * + *

    {@code -i} asserts only against idle sleep — it does not stop the lid closing or an + * operator-requested sleep from taking effect. That is deliberate: this class exists to stop an + * unattended host from sleeping out from under a member's long turn, never to override the + * operator. {@code -s}/{@code -d} (which also block system/display sleep on demand) are + * intentionally not used here. + * + *

    {@link #acquire()} never throws. It returns {@code null} — a no-op — off macOS, and again if + * starting the {@code caffeinate} child fails for any reason (binary missing, process table full, + * …); either case is logged once at INFO, not on every occurrence, so a daemon that runs for + * weeks with the tool unavailable does not fill its log. + */ +public final class CaffeinateSleepAssertionMechanism implements SleepAssertionMechanism { + + private static final Logger log = LoggerFactory.getLogger(CaffeinateSleepAssertionMechanism.class); + + private final AtomicBoolean loggedOnce = new AtomicBoolean(false); + + /** {@code true} when running on macOS, the only platform {@code caffeinate} ships on. */ + public static boolean isSupportedPlatform() { + return isSupportedPlatform(System.getProperty("os.name")); + } + + /** Package-visible so a test can drive the platform check without touching a real property. */ + static boolean isSupportedPlatform(String osName) { + return osName != null && osName.toLowerCase(Locale.ROOT).contains("mac"); + } + + @Override + public SleepAssertion acquire() { + if (!isSupportedPlatform()) { + logOnce("not running on macOS (os.name={}); the idle-sleep guard is a no-op on this platform", + System.getProperty("os.name")); + return null; + } + try { + Process process = new ProcessBuilder("caffeinate", "-i") + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + return new CaffeinateAssertion(process); + } catch (IOException | RuntimeException e) { + logOnce("could not start 'caffeinate -i' ({}); the host may idle-sleep while members are live", + e.toString()); + return null; + } + } + + private void logOnce(String format, Object arg) { + if (loggedOnce.compareAndSet(false, true)) { + log.info("idle-sleep guard: " + format, arg); + } + } + + /** Wraps the live {@code caffeinate} child; {@link #close} force-destroys it, idempotently. */ + private static final class CaffeinateAssertion implements SleepAssertion { + + private final Process process; + + CaffeinateAssertion(Process process) { + this.process = process; + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + process.destroy(); + try { + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + } +} diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java b/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java new file mode 100644 index 0000000..8aad4df --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java @@ -0,0 +1,105 @@ +package dev.ltms.fleet.power; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.IntSupplier; + +/** + * Holds an OS-level assertion against idle sleep for exactly as long as at least one fleet + * member is live. + * + *

    Why this exists: a fleetd host was measured idle-sleeping after as little + * as one minute of inactivity (its {@code pmset -g custom} reports {@code sleep 1} on battery). + * Overnight the daemon's AMQP link to the broker dropped 13 times, and cross-checking every drop + * minute against {@code pmset -g log} found a sleep or wake event in the same minute or the one + * before, every time. The AMQP churn is only the visible symptom — the real problem is that a + * member mid-turn freezes with the host, and a long turn with nobody typing is exactly the case + * that goes idle. + * + *

    How it tracks "live": this is driven by {@code SessionManager}'s existing + * {@code onAcquire}/{@code onRelease} lifecycle hooks (added for CB-520/CB-516, previously wired + * to nothing but the reply inbox) rather than a second member count kept in parallel. Wire it as: + *

    {@code
    + * IdleSleepGuard guard = new IdleSleepGuard(mechanism, sessions::size);
    + * sessions.onAcquire(_ -> guard.recheck());
    + * sessions.onRelease(_ -> guard.recheck());
    + * }
    + * Every acquire/release event re-reads {@code SessionManager#size()} — the same registry {@code + * fleet_list}'s live/capacity numbers are themselves computed from — and only an actual 0→1 or + * 1→0 crossing touches the OS. A listener exception is already caught and logged by {@code + * SessionManager} itself (it must never let a listener failure block the acquire/release it is + * reacting to), so {@link #recheck()} does not need its own top-level try/catch to honor that. + * + *

    Failure posture: every method here is safe to call whether or not {@link + * SleepAssertionMechanism#acquire()} actually works. A mechanism that returns {@code null} (wrong + * platform, missing tool, spawn failure) simply means this guard never holds anything — it never + * throws and never blocks a spawn, a release, or shutdown. + */ +public final class IdleSleepGuard implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(IdleSleepGuard.class); + + private final SleepAssertionMechanism mechanism; + private final IntSupplier liveCount; + private final Object lock = new Object(); + private SleepAssertion held; + + public IdleSleepGuard(SleepAssertionMechanism mechanism, IntSupplier liveCount) { + this.mechanism = mechanism; + this.liveCount = liveCount; + } + + /** + * Re-read the live count and acquire or release the held assertion to match: nothing held and + * at least one member live ⇒ acquire; something held and no member live ⇒ release. A steady + * count (still zero, still positive) is a no-op either way, so a single spawn or release only + * ever touches the OS on the crossing, not on every call. + */ + public void recheck() { + synchronized (lock) { + int live = liveCount.getAsInt(); + if (live > 0 && held == null) { + held = mechanism.acquire(); + if (held != null) { + log.debug("idle-sleep guard armed: {} live member(s)", live); + } + } else if (live == 0 && held != null) { + releaseHeldLocked(); + } + } + } + + /** {@code true} while an assertion is actually held. Exposed for tests. */ + boolean isHeld() { + synchronized (lock) { + return held != null; + } + } + + /** + * Release whatever is held, if anything. Idempotent and safe to call at any time, including + * repeatedly — a daemon shutdown hook calls this unconditionally so no assertion (and no + * {@code caffeinate} child) survives the process, even if the drain that would otherwise have + * driven the live count to zero was itself interrupted or threw. + */ + @Override + public void close() { + synchronized (lock) { + if (held != null) { + releaseHeldLocked(); + } + } + } + + /** Caller must hold {@link #lock}. */ + private void releaseHeldLocked() { + try { + held.close(); + } catch (RuntimeException e) { + log.warn("idle-sleep guard: failed to release its assertion cleanly: {}", e.toString()); + } finally { + held = null; + } + } +} diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java new file mode 100644 index 0000000..4dbe4d9 --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java @@ -0,0 +1,11 @@ +package dev.ltms.fleet.power; + +/** + * A held OS-level assertion against idle sleep. {@link #close} must be idempotent — safe to call + * more than once — and must never throw, matching {@link IdleSleepGuard}'s "never break the + * fleet" contract. + */ +public interface SleepAssertion extends AutoCloseable { + @Override + void close(); +} diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java new file mode 100644 index 0000000..ae58461 --- /dev/null +++ b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java @@ -0,0 +1,21 @@ +package dev.ltms.fleet.power; + +/** + * The OS mechanism {@link IdleSleepGuard} uses to hold and release an idle-sleep assertion. This + * is the seam a test exercises instead of the real effect (a live {@code caffeinate} child) — see + * {@code IdleSleepGuardTest}. + * + *

    Implementations must never throw. Every failure — wrong platform, missing tool, a spawn + * error — must show up as {@link #acquire()} returning {@code null}, so a caller can treat "no + * assertion held" and "the mechanism could not be used" identically and the fleet keeps running + * either way. + */ +public interface SleepAssertionMechanism { + + /** + * Acquire a fresh assertion against idle sleep, or {@code null} when this mechanism is not + * usable right now (wrong platform, the tool is missing, the child process could not start). + * Never throws. + */ + SleepAssertion acquire(); +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java index aea7bba..c0b861e 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java @@ -107,6 +107,7 @@ class ConfigRefTopLevelReportingCoverageTest { v.put("coordinator", new FleetConfig.Coordinator("amqp://coord-a", null, "self-a", 1)); v.put("worktreeGroup", "group-a"); v.put("memberLoginShell", null); + v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true)); assertNamesMatchComponents(v); return v; } @@ -147,6 +148,7 @@ class ConfigRefTopLevelReportingCoverageTest { v.put("coordinator", new FleetConfig.Coordinator("amqp://coord-b", null, "self-b", 2)); v.put("worktreeGroup", "group-b"); v.put("memberLoginShell", null); + v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(false)); assertNamesMatchComponents(v); return v; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java index 4292139..51e9b67 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -2580,4 +2580,58 @@ class FleetConfigTest { "with no pool to choose from, every configured profile is a candidate and the " + "first one wins"); } + + // ── idle-sleep guard: default-on config block ─────────────────────────────────────────────── + + @Test + void idleSleepGuardIsOnByDefaultWhenTheBlockIsEntirelyAbsent(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8080 + """); + + FleetConfig cfg = FleetConfig.load(f); + assertNull(cfg.idleSleepGuard(), "an absent block parses to null, unlike most other blocks here"); + // The block itself is absent, but the FEATURE stays on: Fleetd treats a null block the + // same as enabled: true (see FleetConfig.idleSleepGuard's javadoc) — this test only pins + // the parse result, the on-by-default behaviour is Fleetd's own null check. + } + + @Test + void idleSleepGuardExplicitlyEnabledIsOn(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + idleSleepGuard: + enabled: true + """); + + FleetConfig cfg = FleetConfig.load(f); + assertTrue(cfg.idleSleepGuard().isEnabled()); + } + + @Test + void idleSleepGuardExplicitlyDisabledIsOff(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + idleSleepGuard: + enabled: false + """); + + FleetConfig cfg = FleetConfig.load(f); + assertFalse(cfg.idleSleepGuard().isEnabled()); + } + + @Test + void idleSleepGuardBlockPresentButEmptyDefaultsToEnabled(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + idleSleepGuard: {} + """); + + FleetConfig cfg = FleetConfig.load(f); + assertTrue(cfg.idleSleepGuard().isEnabled(), + "unlike ConfigReload/Health, this block defaults to ON even when present but empty"); + } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java b/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java new file mode 100644 index 0000000..3d1c70d --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java @@ -0,0 +1,54 @@ +package dev.ltms.fleet.power; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Platform-detection unit tests for {@link CaffeinateSleepAssertionMechanism}. + * + *

    This deliberately never calls {@link CaffeinateSleepAssertionMechanism#acquire()} itself — + * doing so on a real macOS machine would actually start a live {@code caffeinate} child and hold + * a real idle-sleep assertion, which the ticket this class exists for explicitly forbids testing + * with. Instead this exercises the pure {@code isSupportedPlatform(String)} predicate that + * {@code acquire()} consults before ever touching {@link ProcessBuilder} — so it proves the + * platform check itself is correct on any CI OS, but it does not prove that a + * real {@code caffeinate -i} spawn succeeds or that its child is torn down correctly; that half is + * exercised indirectly by {@link IdleSleepGuardTest} against a {@link FakeSleepAssertionMechanism} + * instead, which is the seam invariant 2/3 in the ticket call for. + */ +class CaffeinateSleepAssertionMechanismTest { + + @Test + void macOsNamesAreSupported() { + assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Mac OS X")); + assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("macOS")); + assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("MAC OS X")); + } + + @Test + void nonMacNamesAreNotSupported() { + assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Linux")); + assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Windows 11")); + } + + @Test + void nullOsNameIsNotSupported() { + assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform(null)); + } + + /** + * The overload {@code isSupportedPlatform()} (no args) reads the JVM's real {@code os.name} — + * proves the wiring is live, without asserting a specific answer (this suite itself must pass + * on both macOS and Linux CI). + */ + @Test + void noArgOverloadReadsRealSystemProperty() { + boolean expected = CaffeinateSleepAssertionMechanism + .isSupportedPlatform(System.getProperty("os.name")); + boolean actual = CaffeinateSleepAssertionMechanism.isSupportedPlatform(); + assertEquals(expected, actual); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java b/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java new file mode 100644 index 0000000..7b8e180 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java @@ -0,0 +1,56 @@ +package dev.ltms.fleet.power; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Recording fake {@link SleepAssertionMechanism} — the seam behind the real OS effect (a live + * {@code caffeinate} child process). No test in this package ever spawns that real process; every + * assertion here is against this fake's own call log instead. + * + *

    Each acquired {@link FakeAssertion} records its own {@code close()} calls, and every + * acquired instance is kept in {@link #acquired} so a test can inspect all of them, including + * ones {@link IdleSleepGuard} has already released. + */ +final class FakeSleepAssertionMechanism implements SleepAssertionMechanism { + + /** Every {@link FakeAssertion} this mechanism has ever handed out, in order. */ + final CopyOnWriteArrayList acquired = new CopyOnWriteArrayList<>(); + + private final AtomicInteger acquireCalls = new AtomicInteger(); + private volatile boolean unavailable = false; + + /** Make the next (and every subsequent) {@link #acquire()} return {@code null}, like a missing tool. */ + void makeUnavailable() { + unavailable = true; + } + + int acquireCallCount() { + return acquireCalls.get(); + } + + @Override + public SleepAssertion acquire() { + acquireCalls.incrementAndGet(); + if (unavailable) { + return null; + } + FakeAssertion a = new FakeAssertion(); + acquired.add(a); + return a; + } + + /** A held fake assertion; records how many times {@code close()} was actually called. */ + static final class FakeAssertion implements SleepAssertion { + private final AtomicInteger closeCalls = new AtomicInteger(); + + int closeCallCount() { + return closeCalls.get(); + } + + @Override + public void close() { + closeCalls.incrementAndGet(); + } + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardTest.java b/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardTest.java new file mode 100644 index 0000000..903886d --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardTest.java @@ -0,0 +1,118 @@ +package dev.ltms.fleet.power; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link IdleSleepGuard} against a {@link FakeSleepAssertionMechanism} — the seam that stands in + * for a real {@code caffeinate} child process. No test in this class ever spawns a real OS + * process or asserts against real idle sleep; every assertion is against the fake's call log + * (how many times {@code acquire()}/{@code close()} were actually called). That proves the + * orchestration — when the guard decides to hold or release an assertion, and that it + * never throws — but it does not prove that {@code caffeinate -i} itself + * actually stops macOS from idle-sleeping; that half is outside what a unit test can safely + * exercise (see {@link CaffeinateSleepAssertionMechanismTest}'s class doc). + */ +class IdleSleepGuardTest { + + @Test + void acquiresOnZeroToOneAndReleasesOnOneToZero() { + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + AtomicInteger liveCount = new AtomicInteger(0); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, liveCount::get); + + assertFalse(guard.isHeld(), "nothing held before any member is live"); + + liveCount.set(1); + guard.recheck(); + assertTrue(guard.isHeld(), "an assertion must be held once a member is live"); + assertEquals(1, mechanism.acquired.size()); + assertEquals(0, mechanism.acquired.get(0).closeCallCount()); + + liveCount.set(0); + guard.recheck(); + assertFalse(guard.isHeld(), "the assertion must be released once the last member goes"); + assertEquals(1, mechanism.acquired.get(0).closeCallCount(), "the SAME held assertion must be closed"); + } + + @Test + void steadyLiveCountDoesNotReacquireOrRerelease() { + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + AtomicInteger liveCount = new AtomicInteger(2); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, liveCount::get); + + guard.recheck(); // 0 -> 2 crossing: acquires + guard.recheck(); // still 2: must be a no-op + guard.recheck(); // still 2: must be a no-op + assertEquals(1, mechanism.acquireCallCount(), "only the crossing touches the mechanism"); + + liveCount.set(1); // 2 -> 1: still > 0, still a no-op + guard.recheck(); + assertTrue(guard.isHeld()); + assertEquals(0, mechanism.acquired.get(0).closeCallCount()); + assertEquals(1, mechanism.acquireCallCount()); + } + + /** + * Invariant 2: a missing/unavailable mechanism must never throw, and the guard must simply + * hold nothing. {@link FakeSleepAssertionMechanism#makeUnavailable()} makes {@code acquire()} + * return {@code null}, exactly like {@link CaffeinateSleepAssertionMechanism} does off macOS + * or when the {@code caffeinate} binary is missing. + */ + @Test + void unavailableMechanismNeverThrowsAndHoldsNothing() { + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + mechanism.makeUnavailable(); + AtomicInteger liveCount = new AtomicInteger(1); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, liveCount::get); + + guard.recheck(); // must not throw + assertFalse(guard.isHeld(), "acquire() returned null, so nothing is held"); + assertEquals(1, mechanism.acquireCallCount()); + + // still must not throw or leak on release, even though nothing was ever actually held + liveCount.set(0); + guard.recheck(); + assertFalse(guard.isHeld()); + + guard.close(); // teardown with nothing held must also be a safe no-op + } + + /** + * Invariant 3 (teardown). This is the test the mutation testing step removes the production + * release call to fail: with {@code releaseHeldLocked()} not invoked from {@link + * IdleSleepGuard#close()}, the held fake assertion's {@code close()} would never be called and + * this assertion would fail. + */ + @Test + void closeReleasesAHeldAssertionEvenWithoutAZeroCrossing() { + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + AtomicInteger liveCount = new AtomicInteger(1); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, liveCount::get); + + guard.recheck(); + assertTrue(guard.isHeld()); + + guard.close(); + + assertFalse(guard.isHeld(), "close() must release whatever is held, independent of live count"); + assertEquals(1, mechanism.acquired.get(0).closeCallCount()); + } + + @Test + void closeIsIdempotent() { + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + AtomicInteger liveCount = new AtomicInteger(1); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, liveCount::get); + + guard.recheck(); + guard.close(); + guard.close(); // must not throw, must not double-release + assertEquals(1, mechanism.acquired.get(0).closeCallCount()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardWiringTest.java new file mode 100644 index 0000000..af8164b --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/power/IdleSleepGuardWiringTest.java @@ -0,0 +1,72 @@ +package dev.ltms.fleet.power; + +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.guard.SubscriptionGuard; +import dev.ltms.fleet.herdr.AgentControl; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.WorkspaceControl; +import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.session.MemberSession; +import dev.ltms.fleet.session.SessionManager; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Proves the wiring {@code Fleetd.main} actually performs — {@code + * sessions.onAcquire(_ -> guard.recheck())} / {@code sessions.onRelease(_ -> guard.recheck())} — + * not just {@link IdleSleepGuard}'s own orchestration logic in isolation + * ({@link IdleSleepGuardTest} already covers that in isolation, which on its own would not catch + * a wiring gap — e.g. an {@code onAcquire} call typo'd to a no-op lambda, or the listener wired to + * the wrong SessionManager instance — see fleetd's own "a test on the seam does not prove the + * caller" lesson). This test builds a real {@link SessionManager} exactly as + * {@code SessionManagerTest} does (a {@link FakeHerdr}-backed {@link ClaudeCodeLauncher}, no live + * herdr process), wires it to an {@link IdleSleepGuard} the same two lines {@code Fleetd.main} + * uses, and drives real {@link SessionManager#acquire} / {@link SessionManager#release} calls. + */ +class IdleSleepGuardWiringTest { + + private SessionManager sessionManager(FakeHerdr herdr) { + FleetConfig.Profile cfg = new FleetConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + List.of("ccs", "ltms-local"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, null, null); + ClaudeCodeLauncher workers = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + return new SessionManager(workers); + } + + @Test + void acquiringAndReleasingRealSessionsDrivesTheGuardThroughTheSameWiringFleetdUses() { + FakeHerdr herdr = new FakeHerdr(); + SessionManager sessions = sessionManager(herdr); + FakeSleepAssertionMechanism mechanism = new FakeSleepAssertionMechanism(); + IdleSleepGuard guard = new IdleSleepGuard(mechanism, sessions::size); + + // The exact two lines Fleetd.main wires up. + sessions.onAcquire(_ -> guard.recheck()); + sessions.onRelease(_ -> guard.recheck()); + + assertFalse(guard.isHeld(), "no member yet: nothing held"); + + MemberSession a = sessions.acquire("ltms-local", "/a", "/caller", "ownerA"); + assertTrue(guard.isHeld(), "0 -> 1: the first live member must arm the guard"); + + MemberSession b = sessions.acquire("ltms-local", "/b", "/caller", "ownerB"); + assertEquals(1, mechanism.acquireCallCount(), "2nd member: still just 1 live-to-2 step, no new acquire"); + + sessions.release(a.paneId()); + assertTrue(guard.isHeld(), "one member still live: the guard must stay armed"); + assertEquals(0, mechanism.acquired.get(0).closeCallCount()); + + sessions.release(b.paneId()); + assertFalse(guard.isHeld(), "1 -> 0: the last member releasing must disarm the guard"); + assertEquals(1, mechanism.acquired.get(0).closeCallCount()); + } +}