Skip to content

Commit 7ad0e66

Browse files
committed
checker: persist offline grace from last successful check via OfflineHandler (survives daemon restart) — prevents reboot-to-farm-grace; falls back to in-memory when never synced
1 parent d5816e7 commit 7ad0e66

1 file changed

Lines changed: 44 additions & 7 deletions

File tree

src/checker.js

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { WarningScheduler } from './warnings.js';
10+
import { OfflineHandler } from './offline.js';
1011

1112
// Activity ID 8 = Screen Time (device-level master switch)
1213
const SCREEN_TIME_ACTIVITY = 8;
@@ -35,6 +36,15 @@ export class Checker {
3536
this._hardLockTimeout = (options.hardLockTimeout || 300) * 1000;
3637
this._gracePeriod = (options.gracePeriod || 300) * 1000;
3738

39+
// Disk-backed offline cache. The grace window is measured from the last
40+
// SUCCESSFUL check (persisted to ~/.allow2/cache.json), so it survives a
41+
// daemon restart during an outage — a restart can no longer reset the
42+
// grace clock (which would let a child farm grace by rebooting).
43+
this._offline = options.offlineHandler || new OfflineHandler({
44+
gracePeriod: options.gracePeriod || 300,
45+
cachePath: options.cachePath,
46+
});
47+
3848
this._tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
3949

4050
// Per-activity state: Map<activityId, { allowed: boolean, remaining: number }>
@@ -142,7 +152,7 @@ export class Checker {
142152
try {
143153
await this._doCheck();
144154
} catch (err) {
145-
this._handleError(err);
155+
await this._handleError(err);
146156
}
147157

148158
if (this._running) {
@@ -168,11 +178,18 @@ export class Checker {
168178
log: true,
169179
});
170180

171-
// Successful API call — clear offline state
181+
// Successful API call — clear offline state and persist the result so
182+
// the offline grace window (and last-known-good decision) survive a
183+
// daemon restart.
172184
if (this._offlineSince) {
173185
this._offlineSince = null;
174186
this._offlineGraceEmitted = false;
175187
}
188+
try {
189+
await this._offline.cacheResult(result);
190+
} catch (_e) {
191+
// Disk write failure is non-fatal — enforcement continues.
192+
}
176193

177194
this._processResult(result);
178195
}
@@ -261,21 +278,41 @@ export class Checker {
261278
}, this._hardLockTimeout);
262279
}
263280

264-
_handleError(err) {
281+
async _handleError(err) {
265282
// HTTP 401 = credentials revoked, device unpaired
266283
if (err && err.status === 401) {
267284
this._emit('unpaired', { error: err });
268285
this.stop();
269286
return;
270287
}
271288

272-
// Network / timeout errors → offline handling
289+
// Network / timeout errors → offline handling.
290+
//
291+
// Grace is measured from the last SUCCESSFUL check, read from the
292+
// disk-backed cache, so it survives a daemon restart mid-outage. If the
293+
// device has NEVER synced (no cache yet), fall back to in-memory
294+
// first-failure tracking so a brand-new device still gets its grace
295+
// window rather than an instant deny.
273296
const now = Date.now();
274-
if (!this._offlineSince) {
275-
this._offlineSince = now;
297+
let offlineDuration;
298+
299+
let elapsedSec = Infinity;
300+
try {
301+
elapsedSec = await this._offline.getGraceElapsed();
302+
} catch (_e) {
303+
elapsedSec = Infinity;
276304
}
277305

278-
const offlineDuration = now - this._offlineSince;
306+
if (elapsedSec !== Infinity && Number.isFinite(elapsedSec)) {
307+
offlineDuration = elapsedSec * 1000;
308+
// Keep _offlineSince coherent for the event payload / diagnostics.
309+
this._offlineSince = now - offlineDuration;
310+
} else {
311+
if (!this._offlineSince) {
312+
this._offlineSince = now;
313+
}
314+
offlineDuration = now - this._offlineSince;
315+
}
279316

280317
if (offlineDuration < this._gracePeriod) {
281318
if (!this._offlineGraceEmitted) {

0 commit comments

Comments
 (0)