1a9c722ad341c178dc418034e51a1d015507db9b
markd
  Fri Oct 2 20:23:47 2026 -0700
Let superTracks nest inside superTracks. refs #38460

A superTrack given a parent used to pass tdbQuery -check -strict and then be
dropped at load, with no warning: the browser reported the outer folder as not
found. Seven places each looked exactly one level up the parent chain, which is
what trackDb.h means by "Folders are superTracks. Currently only one level deep".

trackDbSuperMarkup   skipped any track that was itself a superTrack when
resolving parent, so the link was never made
rFindTrack           looked "to the sky" one level, so the outer super could
not be found by name
addChildRefsToParents only walked tdbList, which excludes superTracks, so an
inner super never reached its parent's children list
findSuperTrack       matched tdb->parent->track only, so hgTrackUi could not
find the outer folder of a hub assembly
flatten              rescued the immediate super parent only, so the outer one
was never written to the table
polishSupers         polished the immediate parent only, so the outer super
reached the write with a NULL shortLabel and segfaulted
groupTrackListAddSuper  made a track for one super above a member
setSuperTrackHasVisibleMembers  marked one level visible

Each now walks the chain. The group list still shows only the outermost folder,
since superTrack members are not listed there; the inner folders appear on its
page with their own controls, as members always have.

Walking the chain makes a parent loop fatal where it used to be harmless, so
trackDbSuperMarkup now checks that the supertracks form a tree once every link
is set, and breaks a loop at the supertrack that closes it, naming that track.
Verified by planting a loop: before, hgTrackDb spun in trackDbSetting and had to
be killed; now it warns and loads. The same hazard in composite parents is
untouched and filed as #38471.

Regression: trackDb built with this and with the released hgTrackDb is
byte-identical on mm39 (20377 rows) and, on a tree with no nesting, on hg38
(55629) and hs1 (782). With a nested trackDb the only difference on hg38 is the
one rescued container row. hgTracks on hg38 renders the same 32 rows and the
same 10 supertrack controls as dev, and the wgEncodeReg member list is
unchanged.

Note for whoever deploys this: trackDbToTxt also has to be rebuilt. It writes
the curated hub trackDb.txt from the table, and a released one emits a member
whose parent stanza is missing, which breaks the hub.

diff --git src/hg/lib/trackDbCustom.c src/hg/lib/trackDbCustom.c
index 4fddeb2ca84..a0e6a842070 100644
--- src/hg/lib/trackDbCustom.c
+++ src/hg/lib/trackDbCustom.c
@@ -613,30 +613,34 @@
 }
 
 char *maybeSkipHubPrefix(char *track)
 {
 if (!startsWith("hub_", track))
     return track;
 
 char *nextUnderBar = strchr(track + sizeof "hub_", '_');
 
 if (nextUnderBar)
     return nextUnderBar + 1;
 
 return track;
 }
 
+#define maxSuperTrackDepth 10
+// No real trackDb nests supertracks anywhere near this deep; the limit is only
+// here so a malformed parent chain cannot spin forever.
+
 void trackDbSuperMarkup(struct trackDb *tdbList)
 /* Set trackDb from superTrack setting */
 {
 struct trackDb *tdb;
 struct hash *superHash = hashNew(0);
 char *setting = NULL;
 char *words[3];
 int wordCt = 0;
 
 /* find supertracks, setup their settings */
 for (tdb = tdbList; tdb != NULL; tdb = tdb->next)
     {
     setting = trackDbLocalSetting(tdb, "superTrack");
     if (!setting)
         continue;
@@ -645,55 +649,82 @@
         {
         if (!hashLookup(superHash, tdb->track))
             {
             hashAdd(superHash, maybeSkipHubPrefix(tdb->track), tdb);
             tdbMarkAsSuperTrack(tdb);
             if ((wordCt > 1) && sameString("show", words[1]))
                 tdb->isShow = TRUE;
             }
         }
     freeMem(words[0]);
     }
 /* adjust settings on supertrack members after verifying they have
  * a supertrack configured in this trackDb */
 for (tdb = tdbList; tdb != NULL; tdb = tdb->next)
     {
-    if (tdbIsSuperTrack(tdb) || tdb->parent != NULL)
+    if (tdb->parent != NULL)
         continue;
     setting = trackDbLocalSetting(tdb, "parent");
-    if (!setting)
+    // A superTrack may name a parent superTrack, which nests it one level deeper.
+    // Its own "superTrack on" is not a parent reference, so only a non-super may
+    // fall back to the old style, where the setting holds the parent's name.
+    if (!setting && !tdbIsSuperTrack(tdb))
         setting = trackDbLocalSetting(tdb, "superTrack");  // Old style
     if (!setting)
         continue;
     wordCt = chopLine(cloneString(setting), words);
     assert(differentString("on", words[0])); // already weeded out "superTrack on"
     char *parentName = maybeSkipHubPrefix(words[0]);
     tdb->parent = hashFindVal(superHash, parentName);
     if (tdb->parent == tdb)
         {
         warn("Track %s lists itself as its own parent, ignoring.", tdb->track);
         tdb->parent = NULL;
         }
     if (tdb->parent)
         {
         tdbMarkAsSuperTrackChild(tdb);
         tdb->parentName = cloneString(parentName);
         if (wordCt > 1)
             tdb->visibility = max(0, hTvFromStringNoAbort(words[1]));
         }
     freeMem(words[0]);
     }
+
+/* Now that every link is set, make sure the supertracks form a tree.  Nesting
+ * means the parent chain is walked rather than looked at one level, so a loop
+ * would hang every walker instead of being harmless.  Only a supertrack can
+ * close a loop here, since nothing else is given a parent by this routine, so
+ * breaking it at a supertrack names the track whose setting is wrong and leaves
+ * the members attached to their folder. */
+for (tdb = tdbList; tdb != NULL; tdb = tdb->next)
+    {
+    if (!tdbIsSuperTrack(tdb) || tdb->parent == NULL)
+        continue;
+    struct trackDb *ancestor;
+    int depth = 0;
+    for (ancestor = tdb->parent; ancestor != NULL; ancestor = ancestor->parent)
+        {
+        if (ancestor == tdb || ++depth > maxSuperTrackDepth)
+            {
+            warn("Supertrack %s is its own ancestor through its parent setting, "
+                 "ignoring that setting.", tdb->track);
+            tdb->parent = NULL;
+            break;
+            }
+        }
+    }
 hashFree(&superHash);
 }
 
 char *trackDbOrigAssembly(struct trackDb *tdb)
 /* return setting from trackDb, if any */
 {
 return (trackDbSetting(tdb, "origAssembly"));
 }
 
 void trackDbPrintOrigAssembly(struct trackDb *tdb, char *database)
 /* Print lift information from trackDb, if any */
 {
 char *origAssembly = trackDbOrigAssembly(tdb);
 if (origAssembly)
     {