diff --git a/scripts/conformance/server-parity.mjs b/scripts/conformance/server-parity.mjs index 08e1d1055..09ba7f2ae 100644 --- a/scripts/conformance/server-parity.mjs +++ b/scripts/conformance/server-parity.mjs @@ -15,6 +15,7 @@ import {checkSdkTimerAuthorArtifacts, checkSdkTimerAuthorPackages} from './serve import {checkSdkSignalDirectionArtifacts, checkSdkSignalDirectionPackages} from './server-parity/sdk-signal-direction-contract.mjs'; import {captureProbe, probeNotes} from './server-parity/probe-capture.mjs'; import {selectFixturePaths} from './server-parity/fixture-selection.mjs'; +import {waitOutScheduleOccurrences} from './server-parity/schedule-occurrence-guard.mjs'; import {writeFixtureSummary, writeRecord} from './server-parity/record-file.mjs'; import {checkCorrectedMarkerArtifacts, checkCorrectedMarkerPackages} from './server-parity/corrected-marker-contract.mjs'; import {checkCorrectedRangeArtifacts, checkCorrectedRangePackages} from './server-parity/corrected-range-contract.mjs'; @@ -106,6 +107,7 @@ try { checkSdkTimerAuthorArtifacts(fixture, record.artifacts); checkSdkSignalDirectionArtifacts(fixture, record.artifacts); if (fixture.patch_deployment?.legacy_marker_history) checkLegacyMarkerArtifacts(fixture, record.artifacts); + if (fixture.schedule_catalog) waitOutScheduleOccurrences(fixture.id); const workflowId = `${values.prefix}-${fixture.id}`; const arguments_ = [resolve(root, 'scripts/conformance/server-parity/probe.php'), '--mode', values.mode, '--fixture', path, '--workflow-id', workflowId]; for (const flag of ['url', 'application-root']) { diff --git a/scripts/conformance/server-parity/schedule-occurrence-guard.mjs b/scripts/conformance/server-parity/schedule-occurrence-guard.mjs new file mode 100644 index 000000000..aff2acb59 --- /dev/null +++ b/scripts/conformance/server-parity/schedule-occurrence-guard.mjs @@ -0,0 +1,58 @@ +// Server #1102: the schedule-catalog fixture keeps live, unpaused schedules +// with real daily occurrences. A recording that spans one lets the target's +// maintenance evaluator fire a schedule, and every response then differs from +// the reviewed expectation. Before that fixture records, wait out any of its +// natural occurrences that falls inside the recording window. + +// Each schedule's daily occurrence as a local wall time in its zone +// (tests/Fixtures/ServerParity/schedule-catalog.json): P1D intervals at +// 00:00 UTC, bf's PT6H offset at 06:00 UTC, alpha's `0 4 * * *` in +// America/New_York and bf's `0 3 * * *` in Europe/Budapest. +export const SCHEDULE_CATALOG_OCCURRENCES = [ + {zone: 'UTC', hour: 0}, + {zone: 'UTC', hour: 6}, + {zone: 'America/New_York', hour: 4}, + {zone: 'Europe/Budapest', hour: 3}, +]; +export const RECORDING_WINDOW_MS = 10 * 60 * 1000; +export const MARGIN_MS = 60 * 1000; + +// The zone's UTC offset in milliseconds at `instant`. +function offsetMs(zone, instant) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {timeZone: zone, hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}) + .formatToParts(new Date(instant)).filter(part => part.type !== 'literal').map(part => [part.type, Number(part.value)])); + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - instant; +} + +// The UTC instant of `hour`:00 local time in `zone` on the given UTC date. +function occurrenceOn(zone, hour, year, month, day) { + const guess = Date.UTC(year, month, day, hour); + return guess - offsetMs(zone, guess - offsetMs(zone, guess)); +} + +// Milliseconds to wait before recording at `now`, 0 when no occurrence falls +// within the window. +export function scheduleCatalogWaitMs(now, occurrences = SCHEDULE_CATALOG_OCCURRENCES, windowMs = RECORDING_WINDOW_MS) { + let wait = 0; + for (let delta = -1; delta <= 1; delta++) { + const date = new Date(now + delta * 86400000); + for (const {zone, hour} of occurrences) { + const at = occurrenceOn(zone, hour, date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + if (at + MARGIN_MS > now && at <= now + windowMs) { + wait = Math.max(wait, at + MARGIN_MS - now); + } + } + } + return wait; +} + +// Block until no occurrence falls within the window, and say why. +export function waitOutScheduleOccurrences(label, now = Date.now()) { + const wait = scheduleCatalogWaitMs(now); + if (wait > 0) { + console.error(`${label}: waiting ${Math.ceil(wait / 1000)}s for a schedule occurrence to pass before recording (Server #1102)`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait); + } + return wait; +} diff --git a/tests/Unit/ServerParityScheduleOccurrenceGuardTest.mjs b/tests/Unit/ServerParityScheduleOccurrenceGuardTest.mjs new file mode 100644 index 000000000..e04b2f89c --- /dev/null +++ b/tests/Unit/ServerParityScheduleOccurrenceGuardTest.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {scheduleCatalogWaitMs, MARGIN_MS} from '../../scripts/conformance/server-parity/schedule-occurrence-guard.mjs'; + +const at = iso => Date.parse(iso); + +test('a recording that would cross the observed 09:00 UTC alpha occurrence waits it out (EST)', () => { + // #1102: alpha's 04:00 America/New_York fired at 09:00:00Z on 2026-11-08. + const now = at('2026-11-08T08:55:00Z'); + assert.equal(scheduleCatalogWaitMs(now), at('2026-11-08T09:00:00Z') + MARGIN_MS - now); +}); + +test('daylight saving moves the New York and Budapest occurrences', () => { + assert.equal(scheduleCatalogWaitMs(at('2026-07-08T07:55:00Z')), 6 * 60 * 1000); // 04:00 EDT = 08:00Z + assert.equal(scheduleCatalogWaitMs(at('2026-07-08T00:55:00Z')), 6 * 60 * 1000); // 03:00 CEST = 01:00Z + assert.equal(scheduleCatalogWaitMs(at('2026-11-08T01:55:00Z')), 6 * 60 * 1000); // 03:00 CET = 02:00Z +}); + +test('UTC interval occurrences and the margin after them', () => { + assert.equal(scheduleCatalogWaitMs(at('2026-11-08T23:58:00Z')), 3 * 60 * 1000); + assert.equal(scheduleCatalogWaitMs(at('2026-11-08T05:59:30Z')), 90 * 1000); + // Just past an occurrence, within the margin, still waits for the margin. + assert.equal(scheduleCatalogWaitMs(at('2026-11-09T00:00:30Z')), 30 * 1000); +}); + +test('a recording far from every occurrence starts at once', () => { + assert.equal(scheduleCatalogWaitMs(at('2026-11-08T12:00:00Z')), 0); + assert.equal(scheduleCatalogWaitMs(at('2026-11-08T07:11:00Z')), 0); +});