Merge #374: fleetd holds off idle sleep while any member is live
Lands PR #355 (fleetd #354's sibling), rebased onto current main by a worker after 39 commits of drift left it unmergeable. The problem, measured on the original branch: a fleetd host idle-slept after as little as one minute (pmset -g custom reported 'sleep 1' on battery). Overnight the daemon's AMQP link dropped 13 times, and every drop minute had a sleep or wake event in pmset -g log in the same minute or the one before. The AMQP churn is the visible symptom; the real cost is a member mid-turn freezing with the host, and a long turn with nobody typing is exactly the case that goes idle. IdleSleepGuard holds an OS-level assertion for as long as at least one member is live. It is driven by SessionManager's existing onAcquire/onRelease hooks rather than a second member count kept in parallel, so it reads the same registry fleet_list's numbers come from, and only a real 0->1 or 1->0 crossing touches the OS. It fails safe: a mechanism that cannot acquire means nothing is ever held, and it never throws, never blocks a spawn, a release, or shutdown. Conflict resolution was the whole job, and all three were in config plumbing: ConfigRef, FleetConfig and ConfigRefTopLevelReportingCoverageTest. The power package is byte-identical to the original branch commit. Verified on this merge, not taken from the worker's report: mvn clean install -> Tests run: 1439, Failures: 0, Errors: 0, BUILD SUCCESS (1425 on main + 14 new: 4 caffeinate, 5 guard, 1 wiring, 4 config) The denominator recount, which the worker flagged as its own weakest number because this file's count has drifted three times before (#330/#333/#337). I counted it mechanically rather than reading it: FleetConfig has 24 canonical record components; COLD_KEYS 5, DEFERRED_KEYS 13, SPLIT_KEYS 3, plus the 3 the javadoc names as hot-excluded (placement, memberCredentials, memberLoginShell). 5+13+3+3 = 24. The javadoc's '24 components: 5 cold, 13 deferred, 3 split, 3 hot-excluded' is correct. The worker's prose called idleSleepGuard the 25th constructor argument; it is the 24th. The code is right, the report was off by one. Mutation run on merge, on the half the worker verified by READING rather than by proving -- it said it had checked that withDefaults()'s final call binds the true canonical constructor. I dropped the trailing idleSleepGuard argument so the call silently binds the 23-arg back-compat overload. It compiles, which is the whole hazard. Caught: 1 failure, 3 errors, BUILD FAILURE, and FleetConfigWithDefaultsPreservesEveryComponentTest names the dropped component and prints its own denominator -- '24 components, 24 checked, 0 excluded, 23 survived'. That test was added on main after this exact defect happened live when idleSleepGuard was added on a sibling branch; the worker had to add the missing entry to it, and doing so is what makes the guard cover this component at all.
This commit is contained in:
@@ -110,6 +110,14 @@ bind:
|
|||||||
# notifications:
|
# notifications:
|
||||||
# mode: disabled
|
# 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 Unix socket. Omit to use the client default
|
||||||
# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}).
|
# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}).
|
||||||
herdrSocket: ~/.config/herdr/herdr.sock
|
herdrSocket: ~/.config/herdr/herdr.sock
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import dev.ltms.fleet.member.MemberCredentialPolicyView;
|
|||||||
import dev.ltms.fleet.member.OpenCodeLauncher;
|
import dev.ltms.fleet.member.OpenCodeLauncher;
|
||||||
import dev.ltms.fleet.placement.BackendOutagePolicy;
|
import dev.ltms.fleet.placement.BackendOutagePolicy;
|
||||||
import dev.ltms.fleet.placement.BackendQuarantine;
|
import dev.ltms.fleet.placement.BackendQuarantine;
|
||||||
|
import dev.ltms.fleet.power.CaffeinateSleepAssertionMechanism;
|
||||||
|
import dev.ltms.fleet.power.IdleSleepGuard;
|
||||||
import io.javalin.Javalin;
|
import io.javalin.Javalin;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -253,6 +255,26 @@ public final class Fleetd {
|
|||||||
System::nanoTime, contextCap, clearAfterTurn);
|
System::nanoTime, contextCap, clearAfterTurn);
|
||||||
liveCountRef.set(profileName -> liveSessionCount(sessions.roster(), profileName));
|
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.
|
// CB-303 part 1: idle-ttl reaper — only when configured, defaults to disabled.
|
||||||
final SessionReaper reaper;
|
final SessionReaper reaper;
|
||||||
if (cfg.lifecycle() != null
|
if (cfg.lifecycle() != null
|
||||||
@@ -704,6 +726,11 @@ public final class Fleetd {
|
|||||||
if (configWatcher != null) configWatcher.stop(); // CB-559: stop polling the config file
|
if (configWatcher != null) configWatcher.stop(); // CB-559: stop polling the config file
|
||||||
mcp.close();
|
mcp.close();
|
||||||
if (reaper != null) reaper.stop();
|
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).
|
// Release the broker connection last among message resources (no-op for the in-memory inbox).
|
||||||
if (replyInbox instanceof AutoCloseable closeable) {
|
if (replyInbox instanceof AutoCloseable closeable) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import java.util.function.Supplier;
|
|||||||
* makes {@code fleet:} split rather than hot — see below.</li>
|
* makes {@code fleet:} split rather than hot — see below.</li>
|
||||||
* <li><strong>Deferred</strong> — accepted into the new snapshot, but the wiring built at startup
|
* <li><strong>Deferred</strong> — accepted into the new snapshot, but the wiring built at startup
|
||||||
* keeps the old value until a restart: {@code lifecycle:}, {@code leadHeartbeat:},
|
* 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}
|
* {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code quarantineCooldownSeconds}
|
||||||
* (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup),
|
* (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup),
|
||||||
* {@code guard:}, {@code worktreeRoot:}, {@code worktreeGroup:} and {@code memberSkills:}
|
* {@code guard:}, {@code worktreeRoot:}, {@code worktreeGroup:} and {@code memberSkills:}
|
||||||
@@ -131,9 +135,9 @@ import java.util.function.Supplier;
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><strong>The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
|
* <p><strong>The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
|
||||||
* recounted again for fleetd #362.</strong> {@code FleetConfig} has 23 top-level record components:
|
* recounted again for fleetd #362, and again after {@code idleSleepGuard:} was added.</strong>
|
||||||
* 5 cold, 12 deferred, 3 split, 3 hot-excluded. Three of them are named nowhere in this file, and
|
* {@code FleetConfig} has 24 top-level record components: 5 cold, 13 deferred, 3 split, 3
|
||||||
* the reason is the same for all
|
* 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
|
* three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are
|
||||||
* <strong>hot</strong> and correctly absent — all three are read live off {@code config.get()}
|
* <strong>hot</strong> and correctly absent — all three are read live off {@code config.get()}
|
||||||
* (placement through the {@code CompositePeerLauncher} supplier the Hot bullet names;
|
* (placement through the {@code CompositePeerLauncher} supplier the Hot bullet names;
|
||||||
@@ -215,7 +219,7 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
|||||||
static final Set<String> DEFERRED_KEYS = Set.of(
|
static final Set<String> DEFERRED_KEYS = Set.of(
|
||||||
"guard", "worktreeRoot", "worktreeGroup", "memberSkills", "primary", "configReload",
|
"guard", "worktreeRoot", "worktreeGroup", "memberSkills", "primary", "configReload",
|
||||||
"leadHeartbeat", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs",
|
"leadHeartbeat", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs",
|
||||||
"quarantineCooldownSeconds", "profiles");
|
"quarantineCooldownSeconds", "profiles", "idleSleepGuard");
|
||||||
|
|
||||||
private final Path path;
|
private final Path path;
|
||||||
private final AtomicReference<FleetConfig> current;
|
private final AtomicReference<FleetConfig> current;
|
||||||
@@ -427,6 +431,14 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
|||||||
if (!Objects.equals(old.configReload(), fresh.configReload())) {
|
if (!Objects.equals(old.configReload(), fresh.configReload())) {
|
||||||
changed.add("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())
|
if (!Objects.equals(old.spawnReadyTimeoutMs(), fresh.spawnReadyTimeoutMs())
|
||||||
|| !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) {
|
|| !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) {
|
||||||
changed.add("spawnReady*");
|
changed.add("spawnReady*");
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ import java.util.regex.PatternSyntaxException;
|
|||||||
* subdirectory of this directory is copied wholesale, with no per-file
|
* subdirectory of this directory is copied wholesale, with no per-file
|
||||||
* allowlist — do not park scratch files or drafts alongside the real skill
|
* allowlist — do not park scratch files or drafts alongside the real skill
|
||||||
* folders, they will be copied into every provisioned worktree too.
|
* folders, they will be copied into every provisioned worktree too.
|
||||||
|
* @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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record FleetConfig(
|
public record FleetConfig(
|
||||||
@@ -144,7 +149,22 @@ public record FleetConfig(
|
|||||||
Coordinator coordinator,
|
Coordinator coordinator,
|
||||||
String worktreeGroup,
|
String worktreeGroup,
|
||||||
String memberLoginShell,
|
String memberLoginShell,
|
||||||
String memberSkills) {
|
String memberSkills,
|
||||||
|
IdleSleepGuard idleSleepGuard) {
|
||||||
|
|
||||||
|
/** Back-compat form before the {@code idleSleepGuard:} block was added. */
|
||||||
|
public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map<String, Profile> 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, String memberSkills) {
|
||||||
|
this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs,
|
||||||
|
spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth,
|
||||||
|
configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup,
|
||||||
|
memberLoginShell, memberSkills, null);
|
||||||
|
}
|
||||||
|
|
||||||
/** Back-compat form before the {@code memberSkills} key was added. */
|
/** Back-compat form before the {@code memberSkills} key was added. */
|
||||||
public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map<String, Profile> profiles,
|
public FleetConfig(Bind bind, String herdrSocket, String memberHerdrSocket, Map<String, Profile> profiles,
|
||||||
@@ -157,7 +177,7 @@ public record FleetConfig(
|
|||||||
this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs,
|
this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs,
|
||||||
spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth,
|
spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth,
|
||||||
configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup,
|
configReload, quarantineCooldownSeconds, memberCredentials, coordinator, worktreeGroup,
|
||||||
memberLoginShell, null);
|
memberLoginShell, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back-compat form before the {@code memberLoginShell} key was added. */
|
/** Back-compat form before the {@code memberLoginShell} key was added. */
|
||||||
@@ -169,7 +189,7 @@ public record FleetConfig(
|
|||||||
MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup) {
|
MemberCredentials memberCredentials, Coordinator coordinator, String worktreeGroup) {
|
||||||
this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs,
|
this(bind, herdrSocket, memberHerdrSocket, profiles, guard, worktreeRoot, lifecycle, spawnReadyTimeoutMs,
|
||||||
spawnReadyPollMs, broker, primary, fleet, leadHeartbeat, health, placement, auth,
|
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. */
|
/** Back-compat form before the {@code worktreeGroup} key was added. */
|
||||||
@@ -1279,6 +1299,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}).
|
||||||
|
*
|
||||||
|
* <p>Unlike most opt-in blocks in this file, this one defaults to <em>on</em>: 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).
|
* The terminal → lead-name map seeded from the legacy singular {@code primary:} pin (CB-530).
|
||||||
*
|
*
|
||||||
@@ -1529,7 +1568,8 @@ public record FleetConfig(
|
|||||||
"bind", "herdrSocket", "memberHerdrSocket", "profiles", "guard", "worktreeRoot",
|
"bind", "herdrSocket", "memberHerdrSocket", "profiles", "guard", "worktreeRoot",
|
||||||
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet",
|
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet",
|
||||||
"leadHeartbeat", "health", "placement", "auth", "configReload", "quarantineCooldownSeconds",
|
"leadHeartbeat", "health", "placement", "auth", "configReload", "quarantineCooldownSeconds",
|
||||||
"memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "memberSkills");
|
"memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "memberSkills",
|
||||||
|
"idleSleepGuard");
|
||||||
|
|
||||||
/** Load and validate config from {@code path}. */
|
/** Load and validate config from {@code path}. */
|
||||||
public static FleetConfig load(Path path) {
|
public static FleetConfig load(Path path) {
|
||||||
@@ -2209,9 +2249,15 @@ public record FleetConfig(
|
|||||||
// memberSkills is left as-is (fleetd #362), like worktreeGroup/memberLoginShell: null/blank
|
// memberSkills is left as-is (fleetd #362), like worktreeGroup/memberLoginShell: null/blank
|
||||||
// is "off", and there is no sane non-null default — the daemon may not even run from a
|
// is "off", and there is no sane non-null default — the daemon may not even run from a
|
||||||
// checkout that ships its own .claude/skills/.
|
// checkout that ships its own .claude/skills/.
|
||||||
|
// 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,
|
return new FleetConfig(b, herdrSocket, memberHerdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs,
|
||||||
broker, primary, f, leadHeartbeat, health, placementOrDefault, a, configReload,
|
broker, primary, f, leadHeartbeat, health, placementOrDefault, a, configReload,
|
||||||
quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, memberSkills);
|
quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, memberSkills,
|
||||||
|
idleSleepGuard);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>{@code -i} asserts only against <em>idle</em> 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.
|
||||||
|
*
|
||||||
|
* <p>{@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p><strong>Why this exists:</strong> 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.
|
||||||
|
*
|
||||||
|
* <p><strong>How it tracks "live":</strong> 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:
|
||||||
|
* <pre>{@code
|
||||||
|
* IdleSleepGuard guard = new IdleSleepGuard(mechanism, sessions::size);
|
||||||
|
* sessions.onAcquire(_ -> guard.recheck());
|
||||||
|
* sessions.onRelease(_ -> guard.recheck());
|
||||||
|
* }</pre>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p><strong>Failure posture:</strong> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
@@ -108,6 +108,7 @@ class ConfigRefTopLevelReportingCoverageTest {
|
|||||||
v.put("worktreeGroup", "group-a");
|
v.put("worktreeGroup", "group-a");
|
||||||
v.put("memberLoginShell", null);
|
v.put("memberLoginShell", null);
|
||||||
v.put("memberSkills", "/skills/a");
|
v.put("memberSkills", "/skills/a");
|
||||||
|
v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true));
|
||||||
assertNamesMatchComponents(v);
|
assertNamesMatchComponents(v);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -149,6 +150,7 @@ class ConfigRefTopLevelReportingCoverageTest {
|
|||||||
v.put("worktreeGroup", "group-b");
|
v.put("worktreeGroup", "group-b");
|
||||||
v.put("memberLoginShell", null);
|
v.put("memberLoginShell", null);
|
||||||
v.put("memberSkills", "/skills/b");
|
v.put("memberSkills", "/skills/b");
|
||||||
|
v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(false));
|
||||||
assertNamesMatchComponents(v);
|
assertNamesMatchComponents(v);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2629,4 +2629,58 @@ class FleetConfigTest {
|
|||||||
"with no pool to choose from, every configured profile is a candidate and the "
|
"with no pool to choose from, every configured profile is a candidate and the "
|
||||||
+ "first one wins");
|
+ "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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -46,7 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
* comments document that it only ever REPLACES a component when the incoming value is {@code null}
|
* comments document that it only ever REPLACES a component when the incoming value is {@code null}
|
||||||
* (or blank, for {@code placement}) — {@code broker}/{@code primary}/{@code leadHeartbeat}/
|
* (or blank, for {@code placement}) — {@code broker}/{@code primary}/{@code leadHeartbeat}/
|
||||||
* {@code configReload}/{@code coordinator}/{@code worktreeGroup}/{@code memberLoginShell}/
|
* {@code configReload}/{@code coordinator}/{@code worktreeGroup}/{@code memberLoginShell}/
|
||||||
* {@code memberSkills} are left as-is unconditionally, and {@code bind}/{@code guard}/{@code lifecycle}/{@code auth}/
|
* {@code memberSkills}/{@code idleSleepGuard} are left as-is unconditionally, and {@code bind}/{@code guard}/{@code lifecycle}/{@code auth}/
|
||||||
* {@code fleet}/{@code quarantineCooldownSeconds}/{@code memberCredentials}/{@code placement} are
|
* {@code fleet}/{@code quarantineCooldownSeconds}/{@code memberCredentials}/{@code placement} are
|
||||||
* replaced only on null/blank input. A value that is never null or blank going in must therefore
|
* replaced only on null/blank input. A value that is never null or blank going in must therefore
|
||||||
* never change coming out, for every current component. No exclusion is needed today.
|
* never change coming out, for every current component. No exclusion is needed today.
|
||||||
@@ -96,6 +96,7 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest {
|
|||||||
v.put("worktreeGroup", "group-guard");
|
v.put("worktreeGroup", "group-guard");
|
||||||
v.put("memberLoginShell", "/bin/zsh");
|
v.put("memberLoginShell", "/bin/zsh");
|
||||||
v.put("memberSkills", "/skills/guard");
|
v.put("memberSkills", "/skills/guard");
|
||||||
|
v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true));
|
||||||
assertNamesMatchComponents(v);
|
assertNamesMatchComponents(v);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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 <strong>not</strong> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<FakeAssertion> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* <em>orchestration</em> — when the guard decides to hold or release an assertion, and that it
|
||||||
|
* never throws — but it does <strong>not</strong> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user