cd0d053cdf8a7f53ee89a920eab46353797a90ed
braney
  Mon Aug 31 15:33:37 2026 -0700
ts: drop the cookie domain so a parked instance can hold a cart, refs #37867

The shared config sets central.domain=.ucsc.edu, and cartWriteCookie puts that on
the cart cookie.  A parked instance answers on loopback, so the browser dropped
the cookie and every request got a fresh cart.  Each track then came up at its
trackDb default instead of the setting the run had asked for.

Nothing errored, which is what made it worth fixing rather than documenting.  The
page still rendered, so a scripted check measured the defaults and reported a
clean pass.  Clicking through a park by hand hid it completely, because hgTracks
puts the hgsid into the links on its own pages; only a run that navigates by
absolute URL has nothing to carry.

setCookieDomain writes an empty central.domain into the frozen hg.conf, which
leaves the domain attribute off the cookie and makes it host-only.  That works
whether the instance is reached as localhost or as 127.0.0.1, so neither the
tunnel line nor any existing script has to change.  HTTPHOST would not do: it
uses the request's own host, and an IP address has a dot in it, so 127.0.0.1
becomes a real Domain attribute and the browser rejects that too.  The login
cookies follow the same setting through getCookieDomainString in wikiLink.c.

It is written the way setUdcDir is, idempotent on a marker comment and called
from both freeze and conf, so "ts conf NNNNN" retrofits an instance frozen
before this existed.

diff --git src/utils/ts/ts src/utils/ts/ts
index 3dca1c37e18..da18e6543b5 100755
--- src/utils/ts/ts
+++ src/utils/ts/ts
@@ -1,29 +1,31 @@
 #!/bin/bash
 #
 # ts - "ticket sandbox": park a UCSC Genome Browser Redmine ticket as its own
 # frozen browser instance on hgwdev.  refs #37867
 #
 # A per-ticket Apache instance, owned by the developer, bound to a loopback high
 # port, reached by an ssh tunnel (yours or a colleague's - all hgwdev users
 # share the loopback, so anyone with an hgwdev account can tunnel in).  The DB
 # and /gbdb are shared (hg.conf points at hgwdev MySQL; /gbdb is shared NFS) and
 # trash is shared with the live CGIs, so only the *code* is frozen: a full copy
 # of cgi-bin-$USER + htdocs-$USER plus a rewritten hg.conf.  Data is left
 # shared on purpose (freeze the code, share the data).  The one exception is the
 # udc cache: each ticket gets its own subtree under the shared trash, so one
-# instance cannot serve another a cache entry it wrote (see setUdcDir).
+# instance cannot serve another a cache entry it wrote (see setUdcDir).  The
+# rewritten hg.conf also drops the cookie domain, or a parked instance could not
+# hold a cart at all (see setCookieDomain).
 #
 # The parked instances and the port registry live under $TS_ROOT, by default
 # $HOME/ticketSandboxes.  On hgwdev, point that at a large local pool (a freeze
 # is a few GB): mkdir /data/home/$USER/ticketSandboxes and symlink it from
 # $HOME, or set TS_ROOT.
 #
 # Subcommands (NNNNN = Redmine ticket number):
 #   create NNNNN [note]   freeze the live sandbox, start an httpd, register it
 #   sync   NNNNN          re-freeze a parked ticket to the current live sandbox
 #   conf   NNNNN          rewrite httpd.conf and refresh the ts hg.conf settings,
 #                         leaving the frozen code alone
 #   start  NNNNN          start the ticket's httpd (e.g. after a reboot)
 #   stop   NNNNN          stop the ticket's httpd
 #   tunnel NNNNN          open an ssh tunnel and print the browser URL
 #   list                  show all parked tickets and their running status
@@ -118,30 +120,31 @@
     # Rewritten, frozen hg.conf:
     #  - include ../cgi-bin/hg.conf -> absolute (avoid self-include loop; the
     #    shared base config is data-side and stays live by design)
     #  - browser.documentRoot -> this ticket's frozen htdocs-$USER
     sed -e 's#^[[:space:]]*include[[:space:]]\+\.\./cgi-bin/hg\.conf#include '"$BASE_HGCONF"'#' \
         -e 's#^[[:space:]]*browser\.documentRoot[[:space:]]*=.*#browser.documentRoot='"$dir"'/'"$HTDOCS_NAME"'#' \
         "$LIVE_CGI/hg.conf" > "$dir/cgi-bin/hg.conf"
     # Per-ticket marker: a densely tiled "RM NNNNN" watermark on the page
     # background, so it stays visible in the gaps around the content and it is
     # obvious which frozen instance you are looking at.  browser.style
     # (cart.c:2993) injects the stylesheet <link>; appended last so it wins.
     makeMarker "$tkt" "$dir"
     printf '\n# ts: per-ticket background marker (refs #37867)\nbrowser.style=/style/tsMarker.css\n' \
         >> "$dir/cgi-bin/hg.conf"
     setUdcDir "$tkt" "$dir"
+    setCookieDomain "$tkt" "$dir"
     echo "Freeze complete ($(du -sh "$dir/cgi-bin" "$dir/$HTDOCS_NAME" 2>/dev/null | awk '{print $1}' | paste -sd'+'))."
 }
 
 # --- generate the per-ticket background watermark image + stylesheet ---------
 # Small tile => the label repeats many times across the page, so at least some
 # copies land in the margins that the content does not cover.
 makeMarker() {
     local tkt="$1" dir="$2"
     local png="$dir/$HTDOCS_NAME/style/tsMarker.png"
     local css="$dir/$HTDOCS_NAME/style/tsMarker.css"
     if command -v convert >/dev/null 2>&1; then
         convert -size 150x70 xc:none -gravity center \
             -fill 'rgba(200,40,40,0.28)' -pointsize 17 -weight 700 \
             -annotate 0x0+0+0 "RM $tkt" "$png" 2>/dev/null || true
     fi
@@ -163,30 +166,51 @@
 # its own subtree.  It still lives in the shared trash, so the trash cleaner
 # ages it out like any other udc cache, and it is still data, not code: sync
 # does not clear it.  Appended to hg.conf, so it overrides the udc.cacheDir in
 # the included base config (the last assignment of a name wins).
 udcDir() { echo "$SHARED_TRASH/udcCache/ts/$1"; }
 
 setUdcDir() {
     local tkt="$1" dir="$2"
     mkdir -p "$(udcDir "$tkt")"
     # freeze() writes hg.conf fresh, but conf() calls this on an existing one
     grep -q '^# ts: per-ticket udc cache' "$dir/cgi-bin/hg.conf" && return 0
     printf '\n# ts: per-ticket udc cache (refs #37867)\nudc.cacheDir=%s\n' \
         "$(udcDir "$tkt")" >> "$dir/cgi-bin/hg.conf"
 }
 
+# --- let the cart survive on a loopback host ---------------------------------
+# The shared config sets central.domain=.ucsc.edu, and cartWriteCookie (cart.c)
+# puts that on the cart cookie.  A parked instance answers on localhost, so the
+# browser drops a cookie scoped to .ucsc.edu, and every request gets a fresh
+# cart.  Track visibility falls back to its trackDb default, quietly: the page
+# still renders, so a scripted run measures the defaults and looks like a clean
+# pass.  Clicking through a park by hand hides it, because hgTracks puts the
+# hgsid in its own page links; a run that navigates by URL has nothing to carry.
+# An empty value leaves the domain attribute off the cookie altogether, which is
+# what a host-only cookie needs, and it works whether the instance is reached as
+# localhost or as 127.0.0.1.  The login cookies follow it too, through
+# getCookieDomainString() in wikiLink.c.  Appended to hg.conf, so it overrides
+# the value in the included base config (the last assignment of a name wins).
+setCookieDomain() {
+    local tkt="$1" dir="$2"
+    # freeze() writes hg.conf fresh, but conf() calls this on an existing one
+    grep -q '^# ts: host-only cart cookie' "$dir/cgi-bin/hg.conf" && return 0
+    printf '\n# ts: host-only cart cookie (refs #37867)\ncentral.domain=\n' \
+        >> "$dir/cgi-bin/hg.conf"
+}
+
 # --- render the per-ticket httpd.conf ----------------------------------------
 writeConf() {
     local tkt="$1" port="$2" dir; dir="$(tsDir "$1")"
     cat > "$dir/httpd.conf" <<EOF
 # Private loopback httpd for RM #$tkt - generated by ts, refs #37867
 ServerName localhost:$port
 ServerRoot "/etc/httpd"
 Listen 127.0.0.1:$port
 
 LoadModule mpm_prefork_module $MODDIR/mod_mpm_prefork.so
 LoadModule unixd_module $MODDIR/mod_unixd.so
 LoadModule authz_core_module $MODDIR/mod_authz_core.so
 LoadModule authz_host_module $MODDIR/mod_authz_host.so
 LoadModule mime_module $MODDIR/mod_mime.so
 LoadModule dir_module $MODDIR/mod_dir.so
@@ -293,31 +317,32 @@
     [[ -n "$running" ]] && startHttpd "$tkt" || echo "RM $tkt re-frozen (httpd was not running)."
 }
 
 # Rewrite httpd.conf from the current template without touching the frozen code.
 # "sync" also re-freezes, which is what you want after more work on the live
 # sandbox, but not when the freeze is the whole point of the park and only the
 # config template moved on.
 cmd_conf() {
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     [[ -d "$(tsDir "$tkt")" ]] || die "no sandbox directory for RM $tkt"
     local running=""; [[ -n "$(pidOf "$tkt")" ]] && running=1
     [[ -n "$running" ]] && stopHttpd "$tkt"
     writeConf "$tkt" "$port"
-    setUdcDir "$tkt" "$(tsDir "$tkt")"   # retrofit a sandbox frozen before this existed
+    setUdcDir "$tkt" "$(tsDir "$tkt")"        # retrofit a sandbox frozen before these existed
+    setCookieDomain "$tkt" "$(tsDir "$tkt")"
     if [[ -n "$running" ]]; then
         startHttpd "$tkt"
     else
         echo "RM $tkt httpd.conf rewritten (httpd was not running)."
     fi
 }
 
 cmd_start()  { validTkt "$1"; startHttpd "$1"; }
 cmd_stop()   { validTkt "$1"; stopHttpd "$1"; }
 
 cmd_port() {   # print the port for a ticket (used by the laptop-side ts wrapper)
     local tkt="$1"; validTkt "$tkt"
     local port; port="$(regPort "$tkt")"
     [[ -n "$port" ]] || die "RM $tkt not found in registry"
     echo "$port"