Skip to content

Parse zone-less playlist date-times as UTC - #7980

Open
itsjamie wants to merge 2 commits into
masterfrom
fix/issue-7268
Open

Parse zone-less playlist date-times as UTC#7980
itsjamie wants to merge 2 commits into
masterfrom
fix/issue-7268

Conversation

@itsjamie

@itsjamie itsjamie commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

This PR will...

Treat EXT-X-PROGRAM-DATE-TIME and DATERANGE START-DATE/END-DATE values 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 Fragment instance 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 replaces mergeDetails, 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 bare Date.parse it 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.parse follows 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:

It SHOULD indicate a time zone and fractional parts of seconds, to at
least millisecond accuracy.  If no time zone is indicated, the client
SHOULD treat the time zone as UTC.

parseDateTime appends Z before parsing when a date-time has no zone designator. Zone-carrying values, date-only values (already UTC per ECMA-262), and rawProgramDateTime are 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, ±HHMM or ±HH would put it. Is there a form I've missed?

Resolves issues:

Closes #7268

Checklist

  • changes have been done against master branch, and PR does not conflict
  • new unit / functional tests have been added (whenever applicable)
  • API or design changes are documented in API.md

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.
Comment thread src/utils/date-time.ts Outdated
@itsjamie

Copy link
Copy Markdown
Collaborator Author

Pushed a follow up comparing the original Date.parse on essentially a hot-loop benchmark that just took 5000 PDT's and ran them and reported the total amount of milliseconds added up in a benchmark.

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 mergeDetails as one of the properties we keep between playlists when we match segments by media sequence, since there is nothing faster than simply not doing the work again. But, I wanted to keep it scoped to this exact piece of work.

One benchmark had mixed PDTs just to make sure the optimization would still work with odd streams;

2025-05-22T00:00:00.000Z
2025-05-22T00:00:30Z
2025-05-22T01:01:00.000+01:00
2025-05-21T19:01:30-05:00
2025-05-22T09:32:00.000+09:30
2025-05-22T00:02:30+00:00
2025-05-22T00:03Z
2025-13-32T25:61:61.000Z
2025-05-22T00:04:00.000Z
2025-05-22T00:04:30Z
... (etc)

and the other had the case we wanted to optimize for:

2025-05-22T00:00:00.000Z
2025-05-22T00:00:06.006Z
2025-05-22T00:00:12.012Z
2025-05-22T00:00:18.018Z
2025-05-22T00:00:24.024Z
2025-05-22T00:00:30.030Z
2025-05-22T00:00:36.036Z
... (etc)

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);

@robwalch

robwalch commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

thought we could look at caching the parse as date and moving into mergeDetails as one of the properties we keep between playlists when we match segments by media sequence, since there is nothing faster than simply not doing the work again.

mergeDetails should be replaced by passing the previously parsed details in to the parse method. Instead of creating new Fragments and parsing segment tags again completely, we could simply verify that the URIs and dates match for the sequence number, and then carry the Fragment instance over to the fragments array. It means moving all the LL part and delta handling into there and whatever else is in mergeDetails. I think we'd still replace the details on the current Level/Track object (not mutate the existing one when present) which is just sitting there in the PlaylistLoaderContext, ripe for the picking.

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
@itsjamie

Copy link
Copy Markdown
Collaborator Author

I was hoping this could be addressed without RegEx or with a better understanding of the performance impact on long playlists with many PDT tags, so that the cost could be offset by additional date-parsing performance improvements.

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HLS DateTime parsing ISO8601 differential

2 participants