Parse zone-less playlist date-times as UTC - #7980
Conversation
ISO 8601 assumes local time when a date-time carries no time zone, and Date.parse follows it, so EXT-X-PROGRAM-DATE-TIME and DATERANGE dates without a zone produced a different timeline in every viewer's time zone. rfc8216bis-17 Section 4.4.4.6 supersedes the ISO behaviour for HLS clients (treat the time zone as UTC), matching Apple's clients. parseDateTime appends Z before parsing when a date-time has no zone designator and warns once, leaving zone-carrying values, date-only values, and rawProgramDateTime untouched. Covers PDT parsing, DATERANGE START-DATE and END-DATE, and the helper's forms with unit tests.
|
Pushed a follow up comparing the original The most noticable here was that for Firefox to get faster and use the JS implementation I had to add the cache lookup for common repeats of YYYY-MM-DD of the PDT with a string match. Otherwise, it was slower than the original implementation. Heap grows a little on Chrome due to JIT codesize, but overall nets a speed improvement on this, and a massive one on Webkit. Full details on actual performance are in the commit message. Also if you have any ideas that can sit local to this happy to give them a try. I thought we could look at caching the parse as date and moving into One benchmark had mixed PDTs just to make sure the optimization would still work with odd streams; and the other had the case we wanted to optimize for: The differential cases mentioned in the commit were generated with the following script. import { parseDateTime } from './date-compiled.mjs';
const cases = [];
// Edge cases
cases.push(
'2025-05-22T12:34:56.789Z',
'2025-05-22T12:34:56Z',
'2025-05-22T12:34Z',
'2025-05-22T12:34:56.789+01:00',
'2025-05-22T12:34:56-05:00',
'2025-05-22T12:34:56+09:30',
'2025-05-22T12:34:56+00:00',
'2025-05-22T12:34:56-00:00',
'2025-04-31T00:00:00Z', // invalid day
'2025-02-29T00:00:00Z', // non-leap
'2024-02-29T00:00:00Z', // leap
'2000-02-29T00:00:00Z', // leap (400 rule)
'1900-02-28T00:00:00Z', // non-leap century
'1900-03-01T00:00:00Z',
'0000-01-01T00:00:00Z',
'0001-01-01T00:00:00Z',
'9999-12-31T23:59:59.999Z',
'1970-01-01T00:00:00.000Z',
'1969-12-31T23:59:59.999Z',
'2025-13-32T25:61:61.000Z', // out of range
'2025-00-10T00:00:00Z',
'2025-01-00T00:00:00Z',
'2025-05-22T24:00:00Z', // hour 24 -> fallback
'2025-05-22T23:60:00Z',
'2025-05-22T23:00:60Z',
'2025-05-22T12:34:56.7Z', // 1 fraction digit -> fallback
'2025-05-22T12:34:56.78Z',
'2025-05-22T12:34:56.789123Z', // 6 digits -> fallback
'2025-05-22T12:34:56+0100', // no colon offset -> fallback
'2025-05-22T12:34:56+24:00',
'2025-05-22T12:34:56+01:60',
'2025-05-22 12:34:56Z', // space separator -> fallback
'2025-05-22T12:34:56.789Z ', // trailing space -> fallback
' 2025-05-22T12:34:56.789Z',
'2025-05-22T12:34:56.789ZX',
'2025-05-22',
'2025-05-22T12:34:56', // zone-less -> fallback (local)
'2025-05-22T12:34:56.789', // zone-less
'+002025-05-22T12:34:56Z', // expanded year -> fallback
'-000001-01-01T00:00:00Z',
'',
'garbage',
'not-a-date-7', // V8 lenient parse
);
// Random strict-ISO round-trips
for (let i = 0; i < 200000; i++) {
const t = Math.floor(Math.random() * 4102444800000) - 1000000000000;
const iso = new Date(t).toISOString();
const mode = i % 6;
if (mode === 0) cases.push(iso);
else if (mode === 1) cases.push(iso.slice(0, 19) + 'Z');
else if (mode === 2) cases.push(iso.slice(0, 16) + 'Z');
else {
const oh = String(Math.floor(Math.random() * 24)).padStart(2, '0');
const om = String(Math.floor(Math.random() * 60)).padStart(2, '0');
const sign = i % 2 ? '+' : '-';
const body = mode === 3 ? iso.slice(0, 23) : iso.slice(0, 19);
cases.push(`${body}${sign}${oh}:${om}`);
}
}
// Sequential same-day values (cache-hit heavy), with date rollovers and
// interleaved invalid/fallback strings to stress cross-call cache state
const seqStart = Date.UTC(2025, 4, 21, 23, 50, 0, 0);
for (let i = 0; i < 20000; i++) {
cases.push(new Date(seqStart + i * 6006).toISOString());
if (i % 50 === 0) cases.push('2025-13-32T25:61:61.000Z');
if (i % 97 === 0) cases.push('2025-05-21T19:01:30-05:00');
}
// Zone-less coverage: fast-path shapes, fallback shapes (long fractions),
// and near-misses that must keep plain Date.parse semantics
cases.push(
'2025-05-22T12:34',
'2025-05-22T12:34:56',
'2025-05-22T12:34:56.789',
'2025-05-22T12:34:56.123456', // long fraction -> fallback, still UTC
'2025-02-29T12:00:00', // invalid zone-less day -> NaN
'2025-05-22T25:00:00', // invalid zone-less hour -> NaN
'2025-05-22T12:34:56z', // lowercase zone -> raw Date.parse
'2025-05-22T12:34:56+0100', // 4-digit offset -> raw Date.parse
'2025-05-22T12:34:56+01', // hour-only offset -> raw Date.parse
'2025-05-22T12:34:5', // truncated seconds
'2025-05-22T12:34:', // dangling colon
'2025-05-22T12:34:56.7', // truncated zone-less fraction
'2025-05-22T12:34:56.', // dangling dot
);
const ZONELESS_START = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i;
// Zone-less ISO date/times are intentionally treated as UTC (#7268);
// everything else must match Date.parse exactly.
function expected(s) {
if (ZONELESS_START.test(s) && !TIMEZONE_SUFFIX.test(s)) {
return Date.parse(s + 'Z');
}
return Date.parse(s);
}
let failures = 0;
for (const s of cases) {
const a = parseDateTime(s);
const b = expected(s);
const same = (Number.isNaN(a) && Number.isNaN(b)) || a === b;
if (!same) {
failures++;
if (failures <= 20) console.log(`MISMATCH ${JSON.stringify(s)}: fast=${a} Date.parse=${b}`);
}
}
console.log(failures === 0 ? `OK: ${cases.length} cases agree` : `${failures} mismatches out of ${cases.length}`);
process.exit(failures === 0 ? 0 : 1); |
|
parseDateTime decided whether to append Z with two regular expressions run ahead of every Date.parse call. Replace them with separator checks at fixed ISO 8601 positions (YYYY-MM-DDTHH:MM) and a trailing zone designator check (Z, or a sign placed for ±HH:MM, ±HHMM, or ±HH). The digits are still left to Date.parse. Non-ISO strings such as RFC 2822 dates no longer match the date-time shape, so they are passed through untouched instead of getting a Z appended and failing to parse. Measured with bperf against a Date.parse pass-through baseline on a 5000-value live-cadence UTC PDT corpus, a 2000-value mixed-shape daterange corpus, and the live corpus with microsecond fractions, wall time per corpus. The regex version cost Chromium +42.87% (397.493us -> 567.906us), Firefox +71.63% (164.452us -> 282.256us), and WebKit +10.91% (652.028us -> 723.187us). This version: Chromium: CPU regression: -18.96% (383.668us -> 456.418us) Live heap: -4.59% (742.041kb -> 776.109kb) Wall-time regression: -18.42% (397.493us -> 470.716us) Anchor drift: -2.59% Firefox: CPU regression: -27.32% (165.192us -> 210.318us) Live heap: equivalent (733.217kb -> 736.458kb) Wall-time regression: -26.84% (164.452us -> 208.596us) Anchor drift: +0.13% WebKit: CPU regression: -11.49% (635.185us -> 708.195us) Live heap: equivalent (678.933kb -> 696.419kb) Wall-time regression: -12.09% (652.028us -> 730.858us) Anchor drift: -2.50% That is roughly 6 to 11 ns per value on desktop, the cost of the zone check itself. The runtime anchors were inconclusive on all three engines (host load 2.5 to 3.3 during the runs), so the percentages carry environment uncertainty; the direction and the gap to the regex version do not. Playlist reloads parse a handful of these values at most, so the absolute cost per reload is in the low microseconds. Tests: 1188 unit tests pass, including the zone forms and the RFC 2822 pass-through in tests/unit/utils/date-time.ts. Bperf-Benchmark: hls.js.playlist-datetime-parse Bperf-Cycle: cycle-81161a059a28e1
c389043 to
7d033af
Compare
@robwalch completely agree. I did the analysis above with the really narrow scope of trying to make the PDT date-parsing as fast as possible. But honestly, it was the wrong lens to look at the problem. I'm going to create an issue to more fully discuss what we should do which is safe having to parse the PDT multiple times in the first place away from the string representation that the parser hands to us. The new commit uses a much simpler detection based on your comment that I made sure still hit every case the earlier fast-path covered, and while there is a performance regression to what was on main, it's known now. |
This PR will...
Treat
EXT-X-PROGRAM-DATE-TIMEandDATERANGESTART-DATE/END-DATEvalues without a time zone as UTC, and warn when a playlist omits the zone.The charcode fast path I pushed earlier is dropped. Rob's suggestion below is the better version of the same idea: pass the previous details into the parser and carry the
Fragmentinstance over when the sequence number, URI and date match, so a PDT never gets parsed twice in the first place. That removes the reason for a faster parser, and it's a bigger change than this PR (it replacesmergeDetails, including the LL-HLS part and delta handling), so I'll take it up separately. What's left here is the UTC behaviour plus a non-regex check for the zone designator. Over a bareDate.parseit costs about 6 to 11 ns per value on desktop Chromium, Firefox and WebKit; the regex version was 2 to 4x that. Numbers are in the second commit.Why is this Pull Request needed?
ISO 8601 assumes local time when no zone is given, and
Date.parsefollows it, so a zone-less playlist produces a different timeline in every viewer's time zone. rfc8216bis-17 Section 4.4.4.6 supersedes the ISO behaviour for HLS clients, matching what Apple's clients already do:parseDateTimeappendsZbefore parsing when a date-time has no zone designator. Zone-carrying values, date-only values (already UTC per ECMA-262), andrawProgramDateTimeare untouched, so an application that wants the display-oriented local interpretation from Section 6.3.3 can still parse the raw string itself.Are there any points in the code the reviewer needs to double check?
This changes timelines for zone-less playlists that happened to rely on local-time parsing, which were never consistent across viewers anyway. Also, the warning fires once per page rather than per playlist load; curious on thoughts here.
The zone check is by position: a trailing
Z, or a sign where±HH:MM,±HHMMor±HHwould put it. Is there a form I've missed?Resolves issues:
Closes #7268
Checklist