fix(commands): rate limit on a monotonic clock, refuse commands before the clock is set

The Pi has no RTC, so an NTP step made Duration.between negative and
commands were refused as too soon. The limit now uses the monotonic
source. While the wall clock is before a floor given by the caller
(e.g. the build date) commands are rejected with 'clock not set'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-24 05:14:28 +02:00
1 parent 17b65dc1c0
commit 2c214c889d
2 files changed
+50 -5

No files matched your search

@@ -45,6 +45,7 @@ public class CommandService implements Commands {
private final ReadingSink audit;
private final Clock clock;
private final LongSupplier nanoTime;
private final Instant earliestPlausible;
private final Duration minInterval;
private final Pause pause;
// Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier.
@@ -58,8 +59,8 @@ public class CommandService implements Commands {
/** Fails when the store cannot be read: pending restores must never be dropped silently. */
public CommandService(List<Setpoint> setpoints, SetpointRegisters registers, RestoreStore store,
ReadingSink audit, Clock clock, LongSupplier nanoTime, Duration minInterval, Pause pause)
throws IOException {
ReadingSink audit, Clock clock, LongSupplier nanoTime,
Instant earliestPlausible, Duration minInterval, Pause pause) throws IOException {
setpoints.forEach(SafetyLimits::require);
setpoints.forEach(setpoint -> allowlist.put(setpoint.name(), setpoint));
this.registers = registers;
@@ -67,6 +68,7 @@ public class CommandService implements Commands {
this.audit = audit;
this.clock = clock;
this.nanoTime = nanoTime;
this.earliestPlausible = earliestPlausible;
this.minInterval = minInterval;
this.pause = pause;
store.load().forEach(restore -> restores.put(restore.register(), restore));
@@ -97,6 +99,10 @@ public class CommandService implements Commands {
return rejected(command, setpoint, "a boost may last at most " + Command.MAX_BOOST.toHours() + " h");
}
var now = clock.instant();
// The Pi has no RTC: before NTP has set the clock, restore times would be nonsense.
if (now.isBefore(earliestPlausible)) {
return rejected(command, setpoint, "clock not set");
}
if (tooSoon(setpoint.register())) {
return rejected(command, setpoint, "too soon after the last write to " + setpoint.name());
}
@@ -59,7 +59,7 @@ class CommandServiceTest {
private CommandService service() throws IOException {
registers.values.putIfAbsent(4144, 45);
registers.values.putIfAbsent(4132, 20);
return new CommandService(List.of(CWU, CO), registers, store, audit::addAll, clock, clock::nanos,
return new CommandService(List.of(CWU, CO), registers, store, audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)),
Duration.ofSeconds(60), () -> {
pauses++;
inPause.countDown();
@@ -261,7 +261,7 @@ class CommandServiceTest {
};
assertThrows(IOException.class, () -> new CommandService(List.of(CWU), registers, broken,
audit::addAll, clock, clock::nanos, Duration.ZERO, () -> { }));
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }));
}
@Test
@@ -539,7 +539,7 @@ class CommandServiceTest {
var typo = new Setpoint("cwu_comfort", 4144, 30, 500, 1);
assertThrows(IllegalArgumentException.class, () -> new CommandService(List.of(typo), registers, store,
audit::addAll, clock, clock::nanos, Duration.ZERO, () -> { }));
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }));
}
@Test
@@ -599,6 +599,41 @@ class CommandServiceTest {
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 40, null)).status());
}
@Test
void refusesCommandsWhileTheClockIsBeforeTheFloor() throws IOException {
var service = service();
clock.stepBack(Duration.ofDays(2));
var result = service.apply(command("cwu_comfort", 50, HOUR));
assertEquals(Status.REJECTED, result.status());
assertEquals("clock not set", result.message());
assertEquals(0, registers.reads);
assertEquals(0, registers.writes);
assertEquals("REJECTED", lastStatus());
clock.advance(Duration.ofDays(2));
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 50, HOUR)).status());
}
@Test
void aClockStepBackwardDoesNotTripTheRateLimit() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Duration.ofSeconds(61));
clock.stepBack(Duration.ofHours(1));
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 40, null)).status());
}
@Test
void aClockStepForwardDoesNotLiftTheRateLimit() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.stepBack(Duration.ofHours(-1));
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 40, null)).status());
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;
@@ -608,6 +643,10 @@ class CommandServiceTest {
nanos += by.toNanos();
}
void stepBack(Duration by) {
now = now.minus(by);
}
long nanos() {
return nanos;
}