Skip to content

Commit bbf1cf3

Browse files
authored
fix(showcase): rewrite --isolate to use temp overlays + slot-based port allocation (CopilotKit#4570)
## Summary - Never mutate originals -- temp overlay in $TMPDIR instead of in-place .iso-bak - Slot-based port allocation via atomic mkdir for parallel support (slot 0 = +200, slot 1 = +400, etc.) - --project-name scoping prevents Docker container/network collisions - Stale slot cleanup via PID check + 2-hour age fallback - LOCAL_PORTS_FILE env var for TS harness to read offset ports ## Test plan - [ ] Originals untouched after isolated run (md5 verified) - [ ] CI green - [ ] Two parallel --isolate runs don't conflict
2 parents f03dab2 + d49dcc4 commit bbf1cf3

5 files changed

Lines changed: 130 additions & 20 deletions

File tree

showcase/harness/src/cli/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ export interface LocalConfig {
2020
}
2121

2222
export function loadConfig(): LocalConfig {
23-
const portsFile = path.join(SHOWCASE_DIR, "shared/local-ports.json");
23+
// Honor LOCAL_PORTS_FILE env var (set by isolation overlay) so the harness
24+
// reads offset ports from a temp file instead of the checked-in original.
25+
const portsFile =
26+
process.env.LOCAL_PORTS_FILE ||
27+
path.join(SHOWCASE_DIR, "shared/local-ports.json");
2428
const localPorts = JSON.parse(fs.readFileSync(portsFile, "utf-8")) as Record<
2529
string,
2630
number

showcase/harness/src/cli/doctor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ const log = createLogger({ component: "doctor" });
1010
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1111
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
1212
const COMPOSE_FILE = path.join(SHOWCASE_DIR, "docker-compose.local.yml");
13-
const PORTS_FILE = path.join(SHOWCASE_DIR, "shared/local-ports.json");
13+
const PORTS_FILE =
14+
process.env.LOCAL_PORTS_FILE ||
15+
path.join(SHOWCASE_DIR, "shared/local-ports.json");
1416

1517
/** Well-known infra service ports. */
1618
const INFRA_PORTS: Record<string, number> = {

showcase/harness/src/cli/lifecycle.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1818
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
1919
const COMPOSE_FILE = path.join(SHOWCASE_DIR, "docker-compose.local.yml");
2020
const INTEGRATIONS_DIR = path.join(SHOWCASE_DIR, "integrations");
21-
const PORTS_FILE = path.join(SHOWCASE_DIR, "shared/local-ports.json");
21+
// Honor LOCAL_PORTS_FILE env var (set by isolation overlay) so the harness
22+
// reads offset ports from a temp file instead of the checked-in original.
23+
const PORTS_FILE =
24+
process.env.LOCAL_PORTS_FILE ||
25+
path.join(SHOWCASE_DIR, "shared/local-ports.json");
2226

2327
/** Well-known infra service ports that aren't in local-ports.json. */
2428
const INFRA_PORTS: Record<string, number> = {

showcase/scripts/cli/_common.sh

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,31 +111,116 @@ wait_healthy() {
111111
# ── Isolation helpers ───────────────────────────────────────────────────────
112112

113113
ISOLATE_NAME=""
114-
ISOLATE_PORT_OFFSET=200
114+
ISOLATE_PORT_OFFSET=0
115+
ISOLATE_SLOT=""
115116
ISOLATE_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

117184
apply_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
130214
with open('$PORTS_FILE') as f:
131215
ports = json.load(f)
132216
offset = {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 "
140225
import re
141226
with open('$COMPOSE_FILE') as f:
@@ -150,22 +235,35 @@ def offset_port(m):
150235
content = re.sub(r'(\s+)- \"(\d+):(\d+)\"', offset_port, content)
151236
content = 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

163257
restore_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
}

showcase/scripts/cli/cmd-test.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ cmd_test() {
101101

102102
need_slug "$slug"
103103

104-
# Apply isolation if requested (must happen before any compose commands)
104+
# Apply isolation if requested (must happen before any compose commands).
105+
# Register the trap BEFORE apply_isolation so cleanup runs even if the
106+
# function itself crashes partway through.
105107
if $use_isolate; then
106-
apply_isolation "${isolate_name:-}"
107108
trap restore_isolation EXIT
109+
apply_isolation "${isolate_name:-}"
108110
fi
109111

110112
# Build the filter description for the info line

0 commit comments

Comments
 (0)