Skip to content

Feat: Full CMCD v1 Spec#1571

Open
bbetter173 wants to merge 39 commits into
rdkcentral:dev_sprint_25_2from
bbetter173:feat/cmcd
Open

Feat: Full CMCD v1 Spec#1571
bbetter173 wants to merge 39 commits into
rdkcentral:dev_sprint_25_2from
bbetter173:feat/cmcd

Conversation

@bbetter173

Copy link
Copy Markdown

No description provided.

bbetter173 and others added 30 commits June 8, 2026 23:57
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n CMCDSerializer.h

- Add CMCDGroup enum class with four values: Object, Request, Session, Status
- Add CMCDEntry struct with key, value, group, isInteger, isQuotedString, isBoolToken fields
- Declare four free functions: RoundToNearest100, QuoteString, CMCDGroupToHeaderKey, SerializeToCMCDMap
- Use #ifndef CMCDSerializer_h guard (matches CMCDHeaders.h style)
- Include RDK Apache-2.0 copyright block and full Doxygen documentation
…target

- Implement RoundToNearest100 using ((value + 50) / 100) * 100 (round-half-up)
- Implement QuoteString with double-quote wrapping and backslash escaping of '"' and '\'
- Implement CMCDGroupToHeaderKey returning "CMCD-Object:" etc. with trailing ':'
- Implement SerializeToCMCDMap: sort entries alphabetically by key (SER-04), apply
  rounding/quoting/boolean encoding per type flags, omit integer entries rounding to zero
- Register CMCDSerializer.cpp and CMCDSerializer.h in support/aampmetrics/CMakeLists.txt
…itives

- Add CMCDSerializerGTest.cpp as the test runner (InitGoogleTest/RUN_ALL_TESTS)
- Add CMCDSerializerTest.cpp with 8 TEST cases covering all serializer primitives:
  - RoundToNearest100: rounds-half-up and sub-fifty-rounds-to-zero
  - QuoteString: wraps and escapes interior double-quotes and backslashes
  - CMCDGroupToHeaderKey: returns correct header keys with trailing colon
  - SerializeToCMCDMap: sorts Object keys alphabetically and rounds integers
  - SerializeToCMCDMap: bool token true emits bare key, bool false omits group
  - SerializeToCMCDMap: quoted-string wraps sid in double-quotes
  - SerializeToCMCDMap: integer rounding to zero omits the entry entirely
- Add CMakeLists.txt linking the REAL CMCDSerializer.cpp (not faked)
- Register CMCDSerializer suite via add_subdirectory in tests/CMakeLists.txt
…zeToCMCDMap

- Add #include "CMCDSerializer.h"
- Replace hand-built CMCDSession+sessionId concatenation with structured
  CMCDEntry{sid, sessionId, Session, isQuotedString=true}
- Call SerializeToCMCDMap to write CMCD-Session: with quoted sid (SER-03)
- Add subclass contract comment: call base first, then serialize own entries
…rows via SerializeToCMCDMap

- Add #include "CMCDSerializer.h" to both files
- Replace CMCDBITRATE/CMCDTOPBITRATE/etc string concatenation with
  std::vector<CMCDEntry> populated with type-flagged entries
- br/tb/bl: isInteger=true (serializer rounds to nearest 100, omits zero)
- nor/nrr: isQuotedString=true (serializer wraps in double-quotes per SER-03)
- bs: isBoolToken=true, pushed only when bufferStarvation=true (SER-06)
- com.comcast-dns/fb/lb: bare tokens (not isInteger) to preserve raw values
- Group mapping, ot-token selection, and nor/nrr branch logic preserved
- SerializeToCMCDMap merges into map; base CMCD-Session: entry survives
…MCDEntry via SerializeToCMCDMap

- Add #include "CMCDSerializer.h" to both files
- Replace CMCDObject+headerName concatenation with CMCDEntry{"ot","m"/"s",Object}
- Remove unused delimiter/headerName/headerValue locals
- SerializeToCMCDMap merges into map; base CMCD-Session: entry preserved
… base + serializer + all subclasses

- New directory support/aampmetrics/test/tests/CMCDSerialization/ with CMakeLists.txt
- CMakeLists links REAL CMCDHeaders.cpp, CMCDSerializer.cpp, and all four subclass .cpp (no Fake)
- CMCDSerializationGTest.cpp runner file (mirrors VideoCMCDHeadersGTest.cpp pattern)
- Registered add_subdirectory(CMCDSerialization) in tests/CMakeLists.txt
…07, CMP-01, CMP-02

- 22 TEST cases covering all Phase 1 requirements
- SER-01/02: br/tb/bl rounding assertions (EXPECT_THAT HasSubstr and EXPECT_EQ)
- SER-03: sid/nor/nrr quoted-string assertions
- SER-04: CMCD-Object exact sort "br=3800,ot=v,tb=6000"; Request comcast-* before nor
- SER-05: exact group key names with single trailing ':'; no double-colon variants
- SER-06: bs bare token when starving; CMCD-Status: absent when not
- SER-07: group mapping correctness across all four groups
- CMP-01: com.comcast-dns/fb/lb retained in CMCD-Request (both nor and nrr branches)
- CMP-02: empty-state DoesNotCrash and Manifest integration; gate note in comment
- Integration test: base Session and subclass Object/Request coexist (merge verified)
…est CMakeLists

- VideoCMCDHeaders/CMakeLists.txt: added CMCDSerializer.cpp to ABR_SOURCES
- AudioCMCDHeaders/CMakeLists.txt: added CMCDSerializer.cpp to ABR_SOURCES
- SubtitleCMCDHeaders/CMakeLists.txt: fixed pre-existing double-set bug (line 38
  overwrote line 37 dropping SubtitleCMCDHeaders.cpp) and added CMCDSerializer.cpp;
  subclass .cpp files now call SerializeToCMCDMap which requires the serializer symbol

Each binary still links the Fake library for no-op CMCDHeaders base setters (unchanged).
No test assertion changes needed: all three existing tests call BuildCMCDCustomHeaders
without asserting output, so no old non-compliant format to update.
…c values

Wrap std::stoi in a try/catch and add an early-continue for empty values
so a CMCDEntry with isInteger=true and a non-numeric or empty value string
omits the key rather than terminating the calling thread with an uncaught
std::invalid_argument or std::out_of_range exception.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CMCDSerializer binary tests pure free functions (RoundToNearest100,
QuoteString, SerializeToCMCDMap) that have no dependency on CMCDHeaders.
Linking the Fake library contradicted the comment at lines 36-39 of this
same file, risked ODR violations on stricter toolchains, and created a
brittle dependency where a Fake change could break an unrelated unit test.

Matches the Fake-free link line already used by CMCDSerialization/CMakeLists.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… headers

When dnsLookUptime==0, mNextRange is empty, and nextUrl is also empty
(the default-constructed state), the serializer was emitting nor=""
which violates CTA-5004's optional-key rule. Also fixes the dns branch
which had the same exposure.

- else branch: changed from unconditional to else-if (!nextUrl.empty())
- dns branch: added inner guard so nor is only pushed when nextUrl non-empty

Also corrects the misleading "merges without clearing" Step 3 comments in
both subclasses to accurately describe the disjoint-group-key behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `if (value <= 0) return 0;` as the first statement so negative sentinel
values (e.g. -1 for "unknown bitrate") and zero return 0 immediately rather
than risking signed-integer overflow when value is near INT_MIN (INT_MIN + 50
is UB in C++). SerializeToCMCDMap already omits entries when the rounded
result is 0, so this also silently drops unset/invalid integer fields.

Update the Doxygen comment in CMCDSerializer.h to document the new contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the function assigned (overwrote) out[kv.first] for each group,
so a second SerializeToCMCDMap call targeting the same CMCD header group
would silently discard the first call's tokens.

The new implementation: if a group key already exists in out, splits both
the pre-existing comma-delimited token string and the new tokens, combines
them, re-sorts the combined set alphabetically by CMCD key name, and rejoins.
This makes the function safe for multi-call patterns where, for example, a
base class seeds CMCD-Session and a subclass adds further Session keys.

Update the Doxygen comment in CMCDSerializer.h to document the merge contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CMCDHeaders has no std::mutex member and no locked method. The include
created an unnecessary transitive dependency in every TU that includes
CMCDHeaders.h (including StreamAbstractionAAMP.h consumers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… merge semantics

Three new tests in CMCDSerializationTest.cpp:

1. CMCDSerialization_Video/NorOmittedWhenNextUrlEmpty (IN-03/WR-01):
   Asserts that a default-constructed VideoCMCDHeaders with no nextUrl set
   does not emit nor= in CMCD-Request. Locks the WR-01 guard.

2. CMCDSerialization_Audio/NorOmittedWhenNextUrlEmpty (WR-01):
   Same assertion for AudioCMCDHeaders.

3. CMCDSerialization_Merge/TwoCallsSameGroupMergesAndSorts (WR-03):
   Calls SerializeToCMCDMap twice into the same CMCD-Session group and
   asserts the result contains all tokens from both calls, alpha-sorted
   (sf < sid < st). Locks the WR-03 merge-semantics fix.

Also add #include "CMCDSerializer.h" needed by the direct SerializeToCMCDMap
call in the merge test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Headers base

- Add mStreamingFormat, mStreamType, mContentId (std::string), mPlaybackRate (float) protected members
- Initialize mPlaybackRate to 1.0f in constructor so pr is omitted by default
- Declare and implement SetStreamingFormat, SetStreamType, SetContentId, SetPlaybackRate virtual setters
- Doxygen /** */ on each declaration per conventions
…aders

- Add FormatPlaybackRate static helper using snprintf %g (strips trailing zeros)
- Add #include <cstdio> for snprintf
- Push v=1 (always), sf (if non-empty), st (if non-empty), pr (if rate != 1.0f),
  cid (if non-empty, isQuotedString=true) into the same entries vector as sid
- Single SerializeToCMCDMap call produces alpha-sorted cid<pr<sf<sid<st<v
- CMCDSerialization target builds cleanly (verified locally)
…ckRate to AampCMCDCollector

- Add DrmMediaFormat.h include so MediaFormat type is visible in header
- Declare three new public methods with Doxygen (mirrors SetBitrates style)
- Implement MediaFormatToSf() file-local helper: DASH->"d", HLS/HLS_MP4->"h",
  Smooth->"s", PROGRESSIVE/OTA/HDMI/COMPOSITE/RMF/UNKNOWN->"" (sf omitted)
- CMCDSetSessionParams: mutex-locked, bCMCDEnabled-gated, loops all
  mCMCDStreamData entries calling SetStreamingFormat + SetContentId (KEYS-07, KEYS-01)
- CMCDSetLiveStatus: loops all entries calling SetStreamType("l"/"v") (KEYS-08)
- CMCDSetPlaybackRate: loops all entries calling SetPlaybackRate(rate) (KEYS-06)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ackRate call sites

- priv_aamp.cpp after mCMCDCollector->Initialize(): derive strippedContentId from
  mManifestUrl by finding first '?' or '#' (T-02-IL: strips auth tokens from cid),
  then call CMCDSetSessionParams(mMediaFormat, strippedContentId) (KEYS-07 + KEYS-01)
- priv_aamp.cpp NotifySpeedChanged end: call CMCDSetPlaybackRate(rate) so all
  trick-play entries and return-to-1x transitions propagate pr (KEYS-06)
- fragmentcollector_hls.cpp after aamp->SetIsLive(context->IsLive()): call
  aamp->mCMCDCollector->CMCDSetLiveStatus(context->IsLive()) (KEYS-08)
- fragmentcollector_mpd.cpp after aamp->SetIsLive(mIsLiveManifest): call
  aamp->mCMCDCollector->CMCDSetLiveStatus(mIsLiveManifest) (KEYS-08)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…KEYS-01/06/07/08/09)

- Fix 6 stale assertions that expected Session=sid only; now include v=1 (KEYS-09 from 02-01)
- Add 16 new TEST(CMCDSerialization_Session, ...) cases covering:
  - VersionAlwaysPresent: KEYS-09 exact sid+v=1 assertion
  - SfHls/SfDash/SfSmooth/SfOmittedWhenEmpty: KEYS-07 sf mapping + omission
  - StLive/StVod/StOmittedBeforeSet: KEYS-08 st mapping + omission
  - PrPresentWhenNotNormal/PrFractional/PrOmittedAtNormalRate: KEYS-06 pr with trailing-zero strip lock
  - CidQuotedWhenSet/CidOmittedWhenEmpty: KEYS-01 quoted cid + omission
  - AllSixKeysAlphaSortedLive/AllSixKeysWithPrTrickPlay: SER-04 exact wire-format lock-ins
  - SidStillFirstAmongSlikeKeys: sf<sid<st ordering guard via EXPECT_LT
- No CMakeLists change (binary already links real CMCDHeaders.cpp; no Fake used)
- Suite result: 41 tests, 41 passed (25 existing + 16 new)
The header file had `@file AampCMCDCollector.cpp` — wrong filename.
Change to `@file AampCMCDCollector.h` to match the actual file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SetTrackData is called from PrivateInstanceAAMP::SetCMCDTrackData on
download threads, concurrent with CMCDGetHeaders (which already locks
myMutex). The stale comment claiming it was an internal GetHeaders
function was wrong; the missing lock_guard was a data race on
mCMCDStreamData. Add std::lock_guard<std::mutex> matching sibling
methods, and update the comment to reflect actual calling context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the query+fragment stripping of mManifestUrl from the priv_aamp.cpp
call site into AampCMCDCollector::CMCDSetSessionParams. The method now
accepts the raw manifest URL and strips up to the earliest of '?' or '#'
before using the result as cid. This co-locates the auth-token-leak
mitigation with the setter, makes it unit-testable, and simplifies the
call site to a single line. Update header declaration to document the
new rawUrl parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Call CMCDSetPlaybackRate(rate) immediately after CMCDSetSessionParams in
Tune(), so the current rate member is seeded into the collector once CMCD
is enabled. Fixes the edge case where NotifySpeedChanged fires before
Initialize (e.g. auto-resume GLib idle task at line 360), causing the
rate to be silently dropped because bCMCDEnabled was false. Without this
seed the first CMCD emission after fast-resume trick-play would omit the
pr key, violating KEYS-06.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ull check

Three new call sites introduced in Phase 2 dereference aamp->mCMCDCollector
without a null guard: CMCDSetLiveStatus in fragmentcollector_hls.cpp:2230,
CMCDSetLiveStatus in fragmentcollector_mpd.cpp:3038, and SetBitrates(Audio)
in fragmentcollector_mpd.cpp:7366. Wrap each with `if (aamp->mCMCDCollector)`
matching the existing guard pattern at fragmentcollector_mpd.cpp:7959 to
prevent latent crashes on torn-down sessions during fast channel-change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…omission guard

Replace `mPlaybackRate != 1.0f` with `std::fabs(mPlaybackRate - 1.0f) > 1e-4f`
in CMCDHeaders::BuildCMCDCustomHeaders. While 1.0f has an exact IEEE-754
representation and AAMP_NORMAL_PLAY_RATE promotes to exactly 1.0f, a future
double-to-float conversion near 1.0 could silently emit or suppress pr. The
epsilon guard is robust to that without affecting genuine trick-play rates.
Add <cmath> include for std::fabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add CMCDSerialization_Session::AllSixKeysSortedWithCidAndPr which asserts
the exact wire format when all six CMCD-Session keys (cid, pr, sf, sid, st,
v) are simultaneously present. Locks the alpha-sort boundary cid<pr<sf<sid<st<v
which is not covered by the five-key tests above (which omit either pr or cid).
42 tests all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bbetter173 and others added 8 commits June 9, 2026 03:01
… fields and setters to CMCDHeaders base

- Add int mFragmentDuration (d key), int mMeasuredThroughput (mtp key), bool mStartupUrgent (su key) as protected members
- Initialize all three to 0/0/false in the constructor initializer list
- Declare and implement SetFragmentDuration, SetMeasuredThroughput, SetStartupUrgent virtual setters
- No change to BuildCMCDCustomHeaders, Session keys, or pre-existing setters
…ubclasses

- Add #include <algorithm> and #include <cmath> to both subclass .cpp files
- d (KEYS-02): CMCDEntry{Object, all-flags-false} guarded mFragmentDuration>0 — exact ms, no rounding
- dl (KEYS-03): derived inline bufferLength/max(|mPlaybackRate|,kMinRate=0.5f), CMCDEntry{Request,isInteger=true}
- mtp (KEYS-04): CMCDEntry{Request,isInteger=true} guarded mMeasuredThroughput>0
- su (KEYS-05): CMCDEntry{Request,isBoolToken=true} guarded mStartupUrgent — bare token like bs
- rtp (KEYS-10): derived bitrate*kRtpFactor=2.0f (CTA-5004 client-discretion), CMCDEntry{Status,isInteger=true}
- ManifestCMCDHeaders and SubtitleCMCDHeaders unchanged
- Fix(Rule 1): relax KeysInCorrectGroups test EXPECT_EQ(status,"bs") to HasSubstr("bs") now that rtp also appears in Status when bitrate>0
- All 42 CMCDSerialization + 9 CMCDSerializer tests pass
…gent collector setters

- CMCDSetFragmentDuration(AampMediaType, int durationMs): single-instance setter for d key
- CMCDSetMeasuredThroughput(AampMediaType, int kbps): single-instance setter for mtp key
- CMCDSetStartupUrgent(AampMediaType, bool): single-instance setter for su key
- All three: mutex-locked, bCMCDEnabled-gated, Phase-2 CR-01 guarded (it!=end()&&it->second)
- Single-instance find() pattern (not all-instances loop) — per-download values
- SetCMCDTrackData: compute mtpKbps=GetNetworkBandwidth()/1000, call CMCDSetMeasuredThroughput
- SetCMCDTrackData: compute suFlag=bufferRedStatus||IsTuneTypeNew, call CMCDSetStartupUrgent
  (recomputed every call so su clears after startup — RESEARCH Pitfall 3)
- GetFile: after mmediaT remap, call CMCDSetFragmentDuration(mmediaT, fragmentDurationMs)
  gated to eMEDIATYPE_VIDEO||eMEDIATYPE_AUDIO only (init/manifest excluded)
  uses remapped mmediaT to hit correct header instance (RESEARCH Pitfall 4)
- All calls match existing mCMCDCollector usage pattern (always-allocated, no extra null guard)
…ation

- KEYS02: d present/omit/no-round (Video + Audio), absent in ManifestCMCDHeaders
- KEYS03: dl present/rounded-100ms/omit-zero/scaled-by-rate
- KEYS04: mtp present/rounded-100kbps/omit-zero
- KEYS05: su bare token when true, omitted when false (no "su=")
- KEYS10: rtp present/rounded-100kbps/omit-zero-bitrate (derived bitrate*2)
- FullSet lock-in: exact CMCD-Object/Request/Status strings for normal play and startup
- Suite: 61/61 CMCDSerialization + 9/9 CMCDSerializer all pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…MCD key set

- SER01: named tests for mtp (4842->4800) and rtp (3842->7700) rounding to 100 kbps
- SER02: named test for dl rounding (2350->2400) and explicit d-not-rounded lock (2150 stays 2150)
- SER04: exact alpha-sort assertions for Object (br<d<ot<tb), full Request group
  (bl<com.comcast-*<dl<mtp<nor<su), and Status (bs<rtp)
- SER06: su bare-token true/false coverage paired with existing bs tests
- SER07: group-mapping tests for d->Object, dl/mtp/su->Request, rtp->Status
- All 72 CMCDSerialization tests pass; CMCDSerializer still 9/9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… null guard

CR-01: In GetFile, add else-if branch for eMEDIATYPE_INIT_VIDEO/INIT_AUDIO that
calls CMCDSetFragmentDuration(mmediaT, 0) to clear mFragmentDuration on the shared
VIDEO/AUDIO CMCDHeaders instance before CMCDGetHeaders. Prevents the preceding
media-segment d value from leaking into init-segment CMCD-Object headers (CTA-5004
§3 violation). The outer if(mCMCDCollector) guard also absorbs the pre-existing
unconditional CMCDGetHeaders call, hardening the entire block.

WR-02: Wrap the CMCDSetPlaybackRate call in NotifySpeedChanged inside
if (mCMCDCollector) { ... } to match the defensive guard style used at all
other mCMCDCollector call sites and protect against null if the construction
path is extended or the GLib idle task fires before mCMCDCollector is set.

Both changes are inspection-verified (libaamp is not locally buildable on this host).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CR-01 regression (CR01_FragmentDurationClearedToZeroOmitsD): locks the
header-level clearing behavior — SetFragmentDuration(2000) then
SetFragmentDuration(0) must produce CMCD-Object with no "d=" token,
proving that a zero value clears the stale duration from a prior
media-segment request.

WR-01 Audio coverage (KEYS10_RtpPresentForAudio,
FullAudioRequest_AllNewKeysNormalPlay, FullAudioRequest_AllNewKeysStartup):
closes the gap where rtp, dl, mtp, and su had no AudioCMCDHeaders tests.
Full-set lock-in asserts exact CMCD-Object / Request / Status strings for
both su=false and su=true scenarios on the Audio subclass.

IN-01 negative-rate dl (KEYS03_DlNegativeRateTreatedAsAbsolute): locks the
fabs() branch — SetPlaybackRate(-2.0f) + SetBufferLength(4000) must produce
dl=2000, identical to the positive-rate equivalent, confirming reverse
trick-play rates are handled correctly.

Test counts: 72 → 77 (5 new). All 77 CMCDSerialization + 9 CMCDSerializer pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bbetter173 bbetter173 requested a review from a team as a code owner June 8, 2026 22:04
The .planning/ directory holds local-only GSD planning artifacts and is
gitignored. Untracking PROJECT.md and the codebase maps that were committed
before the gitignore was added; local copies are retained on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rdkcmf-jenkins

Copy link
Copy Markdown

b'## Blackduck scan failure details

Summary: 0 violations, 0 files pending approval, 6 files pending identification.

  • Protex Server Path: /home/blackduck/github/aamp/1571/rdk/components/generic/aamp

  • Commit: 43ab76a

Report detail: gist'

@rdkcmf-jenkins

Copy link
Copy Markdown

b'## Blackduck scan failure details

Summary: 0 violations, 0 files pending approval, 6 files pending identification.

  • Protex Server Path: /home/blackduck/github/aamp/1571/rdk/components/generic/aamp

  • Commit: 579cec1

Report detail: gist'

@rdkcmf-jenkins

Copy link
Copy Markdown

b'## Blackduck scan failure details

Summary: 0 violations, 0 files pending approval, 6 files pending identification.

  • Protex Server Path: /home/blackduck/github/aamp/1571/rdk/components/generic/aamp

  • Commit: 579cec1

Report detail: gist'

@rdkcmf-jenkins

Copy link
Copy Markdown

b'## WARNING: A Blackduck scan failure has been waived

A prior failure has been upvoted

  • Upvote reason: ok

  • Commit: 579cec1
    '

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.

2 participants