8845ab537236d54abbc2b6815ea80de681ac21ef
max
  Sat Sep 26 22:08:34 2026 -0700
hgTracks: one track-data download at a time, a second click used to leave a timer nothing could stop

The download dialog polls a 200ms timer while it waits on the api and keeps
that timer id in one field. The Download button stays live while a request is
in flight, so a second click started a second timer and overwrote the id of
the first. Nothing could stop the first one after that: it went on firing
every 200ms once the data had been handed over and cleared, finding nothing
to build a file from each time, which since the GenBank output landed means
an alert box five times a second until the page is reloaded.

startDownload now returns if a download is already running, and the three
places that stop the timer go through stopWaiting(), which forgets the id as
well as clearing it, so the next download can start and a failed request does
not wedge the dialog.

refs #38433

diff --git src/hg/js/hgTracks.js src/hg/js/hgTracks.js
index f1296e3a450..01b6199491e 100644
--- src/hg/js/hgTracks.js
+++ src/hg/js/hgTracks.js
@@ -7403,30 +7403,38 @@
 
     // the keys of an api getData/track reply that are not tracks
     nonTrackKeys: new Set(["chrom", "dataTime", "dataTimeStamp", "downloadTime", "downloadTimeStamp",
         "start", "end", "track", "trackType", "genome", "itemsReturned", "columnTypes",
         "bigDataUrl", "chromSize", "hubUrl"]),
 
     failedTrackDataRequest: function(msg) {
         msgJson = JSON.parse(msg);
         alert("Download failed. Error message: '" + msgJson.error);
     },
 
     receiveTrackData: function(track, data) {
         downloadCurrentTrackData.downloadData[track] = data;
     },
 
+    stopWaiting: function() {
+        // clear the timer that waits on the api and forget its id, so that the next
+        // download can start.  The id has to be forgotten and not just cleared: it is
+        // the one place that records whether a download is still running
+        clearInterval(downloadCurrentTrackData.intervalId);
+        downloadCurrentTrackData.intervalId = null;
+    },
+
     convertJson: function(data, outType, withHeaders) {
         if (outType !== "tsv" && outType !== "csv") {
             alert("ERROR: incorrect output format option");
             return null;
         }
         let outSep = outType === "tsv" ? '\t' : ',';
         // TODO: someday we will probably want to include some of these fields
         // for each track downloaded, perhaps as an option
         let ignoredKeys = downloadCurrentTrackData.nonTrackKeys;
         let columnTypes;
         let cleanData = {};
         // first get rid of top level non track object keys
         _.each(data, function(val, key) {
             if (ignoredKeys.has(key)) {
                 // squirrel away the columnTypes if requested
@@ -7762,31 +7770,31 @@
         str += "ORIGIN      \n";
         for (let i = 0; i < seq.length; i += 60) {
             let line = String(i + 1).padStart(9);
             for (let j = 0; j < 60; j += 10) {
                 line += " " + seq.slice(i + j, i + j + 10);
             }
             str += line.replace(/\s+$/, "") + "\n";
         }
         str += "//\n";
         return new Blob([str], {type: "text/plain"});
     },
 
     makeDownloadFile: function(key) {
         if (_.keys(downloadCurrentTrackData.currentRequests).length === 0) {
             // first stop the timer so we don't execute again
-            clearInterval(downloadCurrentTrackData.intervalId);
+            downloadCurrentTrackData.stopWaiting();
             let outType = $("#outputFormat")[0].selectedOptions[0].value;
             let withHeaders = document.getElementById("downloadTrackHeaders").checked;
             var blob = null;
             if (outType === 'json') {
                 blob = new Blob([JSON.stringify(downloadCurrentTrackData.downloadData[key])], {type: "text/plain"});
             } else if (outType === 'gb') {
                 blob = downloadCurrentTrackData.convertGenbank(downloadCurrentTrackData.downloadData[key],
                     downloadCurrentTrackData.sequenceData);
             } else {
                 blob = downloadCurrentTrackData.convertJson(downloadCurrentTrackData.downloadData[key], outType, withHeaders);
             }
             if (blob) {
                 anchor = document.createElement("a");
                 anchor.href = URL.createObjectURL(blob);
                 fname = $("#downloadFileName")[0].value;
@@ -7805,30 +7813,38 @@
                         break;
                     default:
                         if (!fname.endsWith(".txt")) {fname += ".txt";}
                         break;
                 }
                 anchor.download = fname;
                 anchor.click();
                 window.URL.revokeObjectURL(anchor.href);
                 downloadCurrentTrackData.downloadData = {};
                 downloadCurrentTrackData.sequenceData = null;
             }
         }
     },
 
     startDownload: function() {
+        // A second click while the first download is still running used to start a second
+        // timer and overwrite the id of the first, leaving a timer nothing could stop:  it
+        // went on firing every 200ms after the data had been handed over and cleared, with
+        // nothing left to build a file from.  One download at a time, and the click that
+        // comes too early is simply ignored, the one already running will finish.
+        if (downloadCurrentTrackData.intervalId !== null) {
+            return;
+        }
         trackList = [];
         downloadCurrentTrackData.trackInfo = {};
         $(".downloadTrackName:checked").each(function(i, elem) {
             trackName = elem.id;
             if (getDb().startsWith("hub_")) {
                 // when we are working with assembly hubs, we undecorate the name
                 trackName = undecoratedTrack(elem.id);
             }
             // the api replies with the undecorated name, keep the trackDb under it
             downloadCurrentTrackData.trackInfo[trackName] = hgTracks.trackDb[elem.id];
             trackList.push(trackName);
         });
         if (trackList.length == 0) {
             alert("At least one track must be selected");
             return;
@@ -7867,56 +7883,56 @@
             }
             downloadCurrentTrackData.sequenceData = null;
             let seqUrl = "../cgi-bin/hubApi/getData/sequence?";
             seqUrl += "chrom=" + chrom;
             seqUrl += ";start=" + start;
             seqUrl += ";end=" + end;
             seqUrl += ";genome=" + db;
             var seqRequest = new XMLHttpRequest();
             downloadCurrentTrackData.currentRequests[seqUrl] = true;
             seqRequest.onreadystatechange = function() {
                 if (4 === this.readyState && 200 === this.status) {
                     downloadCurrentTrackData.sequenceData = JSON.parse(this.responseText);
                     delete downloadCurrentTrackData.currentRequests[seqUrl];
                 } else {
                     if (4 === this.readyState && this.status >= 400) {
-                        clearInterval(downloadCurrentTrackData.intervalId);
+                        downloadCurrentTrackData.stopWaiting();
                         downloadCurrentTrackData.failedTrackDataRequest(this.responseText);
                         delete downloadCurrentTrackData.currentRequests[seqUrl];
                     }
                 }
             };
             seqRequest.open("GET", seqUrl, true);
             seqRequest.send();
         }
         apiUrl = "../cgi-bin/hubApi/getData/track?";
         apiUrl += "chrom=" + chrom;
         apiUrl += ";start=" + start;
         apiUrl += ";end=" + end;
         apiUrl += ";genome=" + db;
         apiUrl += ";jsonOutputArrays=1";
         apiUrl += ";track=" + trackList.join(',');
         var xmlhttp = new XMLHttpRequest();
         downloadCurrentTrackData.currentRequests[apiUrl] = true;
         xmlhttp.onreadystatechange = function() {
             if (4 === this.readyState && 200 === this.status) {
                 var mapData = JSON.parse(this.responseText);
                 downloadCurrentTrackData.receiveTrackData(apiUrl, mapData);
                 delete downloadCurrentTrackData.currentRequests[apiUrl];
             } else {
                 if (4 === this.readyState && this.status >= 400) {
-                    clearInterval(downloadCurrentTrackData.intervalId);
+                    downloadCurrentTrackData.stopWaiting();
                     downloadCurrentTrackData.failedTrackDataRequest(this.responseText);
                     delete downloadCurrentTrackData.currentRequests[apiUrl];
                 }
             }
         };
         xmlhttp.open("GET", apiUrl, true);
         xmlhttp.send();  // sends request and exits this function
         // the onreadystatechange callback above will trigger
         // when the data has safely arrived
         // wait for the request to complete before making the download file
         downloadCurrentTrackData.intervalId = setInterval(downloadCurrentTrackData.makeDownloadFile, 200, apiUrl);
     },
 
 
     // file name suffix per output format, the same ones makeDownloadFile appends