Merge pull request #2288 from glifocat/fix/host-sweep-tz-utc-parsing

fix(host-sweep): parse SQLite timestamps as UTC, not local time
This commit is contained in:
gavrielc
2026-05-06 00:55:59 +03:00
committed by GitHub
2 changed files with 55 additions and 2 deletions
+42
View File
@@ -12,6 +12,7 @@ import {
CLAIM_STUCK_MS,
_resetStuckProcessingRowsForTesting,
decideStuckAction,
parseSqliteUtc,
} from './host-sweep.js';
import type { Session } from './types.js';
@@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => {
expect(row.tries).toBe(1); // not bumped, the skip path held
});
});
describe('parseSqliteUtc', () => {
// Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse
// treats those as local time. On non-UTC hosts this made every claim look
// (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages.
// The helper appends "Z" only when no marker is present, so parsing is
// always anchored to UTC regardless of host timezone.
const utcMs = Date.parse('2026-04-20T12:00:00.000Z');
it('treats a SQLite-style timestamp (no zone) as UTC', () => {
expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs);
});
it('preserves an explicit Z marker', () => {
expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs);
});
it('preserves an explicit numeric offset', () => {
// 14:00+02:00 == 12:00 UTC
expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs);
// 07:00-05:00 == 12:00 UTC
expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs);
});
it('returns NaN for unparseable input', () => {
expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true);
});
it('does not drift across host timezones for SQLite-style input', () => {
// The helper itself is timezone-independent because it forces UTC parsing.
// (Verifying the regex branch — without the helper, `Date.parse` of the
// bare string returns different values depending on the host TZ.)
const bare = '2026-04-20T12:00:00';
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
});
});
+13 -2
View File
@@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
import type { Session } from './types.js';
/**
* SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
* treats timezoneless ISO strings as local time, so on non-UTC hosts every
* timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
* decisions on freshly-claimed messages. Append "Z" when no zone marker is
* present so Date.parse interprets the string as UTC.
*/
export function parseSqliteUtc(s: string): number {
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
}
const SWEEP_INTERVAL_MS = 60_000;
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
// been touched in this long, the container is either stuck or doing genuinely
@@ -95,7 +106,7 @@ export function decideStuckAction(args: {
const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
for (const claim of claims) {
const claimedAt = Date.parse(claim.status_changed);
const claimedAt = parseSqliteUtc(claim.status_changed);
if (Number.isNaN(claimedAt)) continue;
const claimAge = now - claimedAt;
if (claimAge <= tolerance) continue;
@@ -275,7 +286,7 @@ function resetStuckProcessingRows(
// Already rescheduled for a future retry — don't bump tries again. The
// wake path (sweep step 2) will fire when process_after elapses and a
// fresh container will clean the orphan claim on startup.
if (msg.processAfter && Date.parse(msg.processAfter) > now) continue;
if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue;
if (msg.tries >= MAX_TRIES) {
markMessageFailed(inDb, msg.id);