@@ -111,31 +111,116 @@ wait_healthy() {
111111# ── Isolation helpers ───────────────────────────────────────────────────────
112112
113113ISOLATE_NAME=" "
114- ISOLATE_PORT_OFFSET=200
114+ ISOLATE_PORT_OFFSET=0
115+ ISOLATE_SLOT=" "
115116ISOLATE_ACTIVE=false
117+ ISOLATE_TMPDIR=" "
118+
119+ ISOLATE_SLOT_DIR=" /tmp/showcase-isolate-slots"
120+ ISOLATE_STALE_THRESHOLD=7200 # 2 hours in seconds
121+
122+ # Claim an isolation slot using atomic mkdir. Slots start at 0 and increment.
123+ # Each slot dir contains a "pid" file for stale-detection. The port offset is
124+ # (slot + 1) * 200, so slot 0 → +200, slot 1 → +400, etc.
125+ _claim_isolate_slot () {
126+ mkdir -p " $ISOLATE_SLOT_DIR "
127+
128+ # Clean up stale slots first (crashed runs older than 2 hours, or dead PIDs)
129+ local slot_entry
130+ for slot_entry in " $ISOLATE_SLOT_DIR " /[0-9]* ; do
131+ [ -d " $slot_entry " ] || continue
132+ local slot_pid_file=" $slot_entry /pid"
133+ if [ -f " $slot_pid_file " ]; then
134+ local slot_pid
135+ slot_pid=" $( cat " $slot_pid_file " 2> /dev/null) "
136+ # If the PID is dead, remove the stale slot
137+ if [ -n " $slot_pid " ] && ! kill -0 " $slot_pid " 2> /dev/null; then
138+ info " Reclaiming stale slot $( basename " $slot_entry " ) (PID $slot_pid dead)"
139+ rm -rf " $slot_entry "
140+ continue
141+ fi
142+ fi
143+ # Fallback: age-based cleanup if no pid file or pid check inconclusive
144+ if [ ! -f " $slot_pid_file " ]; then
145+ local slot_age
146+ if [[ " $OSTYPE " == darwin* ]]; then
147+ slot_age=$(( $(date +% s) - $(stat - f % m "$slot_entry ") ))
148+ else
149+ slot_age=$(( $(date +% s) - $(stat - c % Y "$slot_entry ") ))
150+ fi
151+ if [ " $slot_age " -gt " $ISOLATE_STALE_THRESHOLD " ]; then
152+ info " Reclaiming stale slot $( basename " $slot_entry " ) (age ${slot_age} s > ${ISOLATE_STALE_THRESHOLD} s)"
153+ rm -rf " $slot_entry "
154+ fi
155+ fi
156+ done
157+
158+ # Claim the first available slot (mkdir is atomic — if it succeeds, we own it)
159+ local n=0
160+ while true ; do
161+ if mkdir " $ISOLATE_SLOT_DIR /$n " 2> /dev/null; then
162+ ISOLATE_SLOT=" $n "
163+ echo " $$ " > " $ISOLATE_SLOT_DIR /$n /pid"
164+ ISOLATE_PORT_OFFSET=$(( (n + 1 ) * 200 ))
165+ return 0
166+ fi
167+ n=$(( n + 1 ))
168+ if [ " $n " -gt 45 ]; then
169+ die " No isolation slots available (0-45 exhausted). Check /tmp/showcase-isolate-slots/"
170+ fi
171+ done
172+ }
173+
174+ # Release the claimed isolation slot
175+ _release_isolate_slot () {
176+ if [ -n " $ISOLATE_SLOT " ] && [ -d " $ISOLATE_SLOT_DIR /$ISOLATE_SLOT " ]; then
177+ rm -rf " $ISOLATE_SLOT_DIR /$ISOLATE_SLOT "
178+ fi
179+ # Remove the parent dir if now empty
180+ rmdir " $ISOLATE_SLOT_DIR " 2> /dev/null || true
181+ ISOLATE_SLOT=" "
182+ }
116183
117184apply_isolation () {
118- local name=" ${1:- isolate-$$ } "
119- ISOLATE_NAME=" $name "
185+ local name=" ${1:- } "
120186 ISOLATE_ACTIVE=true
187+
188+ # Guard: clean up stale .iso-bak files from a prior botched run that
189+ # mutated originals in-place (the old approach). This makes migration safe.
190+ if [ -f " ${PORTS_FILE} .iso-bak" ] || [ -f " ${COMPOSE_FILE} .iso-bak" ]; then
191+ warn " Stale .iso-bak files found from a prior crash — restoring originals"
192+ [ -f " ${PORTS_FILE} .iso-bak" ] && mv " ${PORTS_FILE} .iso-bak" " $PORTS_FILE "
193+ [ -f " ${COMPOSE_FILE} .iso-bak" ] && mv " ${COMPOSE_FILE} .iso-bak" " $COMPOSE_FILE "
194+ fi
195+
196+ # Claim a slot for unique port offsets
197+ _claim_isolate_slot
198+
199+ # Build the isolation name, incorporating the slot for uniqueness
200+ if [ -z " $name " ]; then
201+ name=" showcase-iso${ISOLATE_SLOT} "
202+ fi
203+ ISOLATE_NAME=" $name "
121204 export COMPOSE_PROJECT_NAME=" $name "
122205
123- # Backup originals
124- cp " $PORTS_FILE " " ${PORTS_FILE} .iso-bak "
125- cp " $COMPOSE_FILE " " ${COMPOSE_FILE} .iso-bak "
206+ # Create temp directory for overlay copies ( originals stay untouched)
207+ ISOLATE_TMPDIR= " ${TMPDIR :-/ tmp} /showcase-isolate- $$ "
208+ mkdir -p " $ISOLATE_TMPDIR "
126209
127- # Offset ports in local-ports.json
210+ # Generate offset ports file in the temp dir
211+ local tmp_ports=" $ISOLATE_TMPDIR /local-ports.json"
128212 python3 -c "
129- import json
213+ import json, sys
130214with open('$PORTS_FILE ') as f:
131215 ports = json.load(f)
132216offset = {k: v + $ISOLATE_PORT_OFFSET for k, v in ports.items()}
133- with open('$PORTS_FILE ', 'w') as f:
217+ with open('$tmp_ports ', 'w') as f:
134218 json.dump(offset, f, indent=2)
135219 f.write('\n')
136220"
137221
138- # Offset host ports and container names in docker-compose.local.yml
222+ # Generate offset compose file in the temp dir
223+ local tmp_compose=" $ISOLATE_TMPDIR /docker-compose.local.yml"
139224 python3 -c "
140225import re
141226with open('$COMPOSE_FILE ') as f:
@@ -150,22 +235,35 @@ def offset_port(m):
150235content = re.sub(r'(\s+)- \" (\d+):(\d+)\" ', offset_port, content)
151236content = content.replace('container_name: showcase-', 'container_name: $name -')
152237
153- with open('$COMPOSE_FILE ', 'w') as f:
238+ with open('$tmp_compose ', 'w') as f:
154239 f.write(content)
155240"
156241
242+ # Override shell variables so all downstream code uses the temp files.
243+ # Originals are NEVER mutated.
244+ COMPOSE_FILE=" $tmp_compose "
245+ COMPOSE_CMD=" docker compose -f $COMPOSE_FILE --project-name $name "
246+ PORTS_FILE=" $tmp_ports "
247+
248+ # Export for the TS harness CLI (config.ts / lifecycle.ts honor this)
249+ export LOCAL_PORTS_FILE=" $tmp_ports "
250+
157251 # Idempotent: tear down any prior run with this name
158252 $COMPOSE_CMD down --remove-orphans 2> /dev/null || true
159253
160- info " Isolation active: project=$name ports=+$ISOLATE_PORT_OFFSET "
254+ info " Isolation active: project=$name slot= $ISOLATE_SLOT ports=+$ISOLATE_PORT_OFFSET tmpdir= $ISOLATE_TMPDIR "
161255}
162256
163257restore_isolation () {
164258 if $ISOLATE_ACTIVE ; then
165- info " Tearing down isolated group: $ISOLATE_NAME "
259+ info " Tearing down isolated group: $ISOLATE_NAME (slot $ISOLATE_SLOT ) "
166260 $COMPOSE_CMD down --remove-orphans 2> /dev/null || true
167- [ -f " ${PORTS_FILE} .iso-bak" ] && mv " ${PORTS_FILE} .iso-bak" " $PORTS_FILE "
168- [ -f " ${COMPOSE_FILE} .iso-bak" ] && mv " ${COMPOSE_FILE} .iso-bak" " $COMPOSE_FILE "
261+ # Just remove the temp dir — originals were never touched
262+ if [ -n " $ISOLATE_TMPDIR " ] && [ -d " $ISOLATE_TMPDIR " ]; then
263+ rm -rf " $ISOLATE_TMPDIR "
264+ fi
265+ # Release the isolation slot so other runs can claim it
266+ _release_isolate_slot
169267 ISOLATE_ACTIVE=false
170268 fi
171269}
0 commit comments