Skip to content

Commit 547292a

Browse files
emerybergerclaude
andcommitted
Add robust server mode for coz plot
Make `coz plot` usable in headless/remote environments and more resilient in local use: - Auto-retry port selection when preferred port is busy (tries +0..+9 then random high ports) - Detect headless environments (SSH, no X11/Wayland) and skip browser opening, printing SSH port-forwarding instructions instead - Add --no-browser flag for explicit server-only mode - Fix Ctrl-C/SIGTERM handling to shut down cleanly without deadlock Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e604eee commit 547292a

1 file changed

Lines changed: 103 additions & 32 deletions

File tree

coz

Lines changed: 103 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ def open_browser(url):
151151
import webbrowser
152152
webbrowser.open_new_tab(url)
153153

154+
def _can_open_browser():
155+
"""Check if we're in an environment where a browser can be opened."""
156+
# SSH session — no local display
157+
if os.environ.get('SSH_TTY') or os.environ.get('SSH_CONNECTION'):
158+
return False
159+
# Linux: need X11 or Wayland
160+
if sys.platform == 'linux':
161+
if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'):
162+
return False
163+
return True
164+
154165
def parse_profile(profile_path, include_raw=False):
155166
"""Parse .coz or .jsonl profile and return aggregated data and metadata."""
156167
import json
@@ -184,8 +195,12 @@ def parse_profile(profile_path, include_raw=False):
184195
record_type = record.get('type', '')
185196

186197
if record_type == 'experiment':
198+
selected_line = record.get('selected', '')
199+
if '/coz.h:' in selected_line:
200+
experiment = None
201+
continue
187202
experiment = {
188-
'selected': record.get('selected', ''),
203+
'selected': selected_line,
189204
'speedup': float(record.get('speedup', 0)),
190205
'duration': int(record.get('duration', 0)),
191206
'selected_samples': int(record.get('selected_samples', 0))
@@ -223,8 +238,9 @@ def parse_profile(profile_path, include_raw=False):
223238
runtime = int(record.get('time', 0))
224239
elif record_type == 'samples':
225240
loc = record.get('location', '')
226-
count = int(record.get('count', 0))
227-
samples[loc] = samples.get(loc, 0) + count
241+
if '/coz.h:' not in loc:
242+
count = int(record.get('count', 0))
243+
samples[loc] = samples.get(loc, 0) + count
228244
else:
229245
# Legacy tab-separated format
230246
parts = line.split('\t')
@@ -236,8 +252,12 @@ def parse_profile(profile_path, include_raw=False):
236252
fields[k] = v
237253

238254
if record_type == 'experiment':
255+
selected_line = fields.get('selected', '')
256+
if '/coz.h:' in selected_line:
257+
experiment = None
258+
continue
239259
experiment = {
240-
'selected': fields.get('selected', ''),
260+
'selected': selected_line,
241261
'speedup': float(fields.get('speedup', 0)),
242262
'duration': int(fields.get('duration', 0)),
243263
'selected_samples': int(fields.get('selected-samples', 0))
@@ -275,8 +295,9 @@ def parse_profile(profile_path, include_raw=False):
275295
runtime = int(fields.get('time', 0))
276296
elif record_type == 'samples':
277297
loc = fields.get('location', '')
278-
count = int(fields.get('count', 0))
279-
samples[loc] = samples.get(loc, 0) + count
298+
if '/coz.h:' not in loc:
299+
count = int(fields.get('count', 0))
300+
samples[loc] = samples.get(loc, 0) + count
280301

281302
return data, experiment_count, runtime, samples, raw_experiments
282303

@@ -615,8 +636,7 @@ def _coz_plot(args):
615636
profile_path = default_profile
616637
break
617638

618-
# Find an available port
619-
port = args.port
639+
profile_basename = os.path.basename(profile_path) if profile_path else None
620640

621641
class CozHandler(http.server.SimpleHTTPRequestHandler):
622642
protocol_version = 'HTTP/1.1'
@@ -867,11 +887,14 @@ def _coz_plot(args):
867887
self.wfile.write(content)
868888
return
869889

870-
# Serve profile.coz from current directory when requested
871-
if self.path == '/current-profile.coz' and profile_path:
890+
# Serve the profile file when requested by its basename
891+
if profile_basename and self.path == '/' + profile_basename and profile_path:
872892
try:
873-
with open(profile_path, 'rb') as f:
874-
content = f.read()
893+
with open(profile_path, 'r') as f:
894+
lines = f.readlines()
895+
# Filter out coz.h self-instrumentation from old profiles
896+
filtered = [l for l in lines if '/coz.h:' not in l]
897+
content = ''.join(filtered).encode('utf-8')
875898
self.send_response(200)
876899
self.send_header('Content-Type', 'text/plain')
877900
self.send_header('Content-Length', len(content))
@@ -1191,30 +1214,74 @@ Based on the causal profiling data, suggest specific optimizations for the targe
11911214
allow_reuse_address = True
11921215
daemon_threads = True
11931216

1194-
try:
1195-
with ThreadedHTTPServer(("", port), CozHandler) as httpd:
1196-
# Build URL with query parameter if profile exists
1197-
if profile_path:
1198-
url = f'http://localhost:{port}/?load=current-profile.coz'
1199-
print(f'Loading profile: {profile_path}')
1200-
else:
1201-
url = f'http://localhost:{port}/'
1202-
print(f'Serving coz viewer at http://localhost:{port}/')
1203-
print(f'Press Ctrl+C to stop the server')
1217+
# Find an available port with retry
1218+
import random
1219+
import signal
1220+
preferred = args.port
1221+
candidates = list(range(preferred, preferred + 10))
1222+
candidates += [random.randint(49152, 65535) for _ in range(5)]
1223+
httpd = None
1224+
port = None
1225+
for candidate in candidates:
1226+
try:
1227+
httpd = ThreadedHTTPServer(("", candidate), CozHandler)
1228+
port = candidate
1229+
break
1230+
except OSError as e:
1231+
if e.errno in (48, 98): # EADDRINUSE (macOS / Linux)
1232+
continue
1233+
raise
1234+
if httpd is None:
1235+
sys.stderr.write('error: could not find an available port (tried %d-%d and random high ports)\n'
1236+
% (preferred, preferred + 9))
1237+
sys.exit(1)
12041238

1205-
# Open browser in a separate thread
1239+
with httpd:
1240+
# Build URL with query parameter if profile exists
1241+
if profile_path:
1242+
url = f'http://localhost:{port}/?load={profile_basename}'
1243+
print(f'Loading profile: {profile_path}')
1244+
else:
1245+
url = f'http://localhost:{port}/'
1246+
if port != preferred:
1247+
print(f'Port {preferred} was busy, using {port} instead')
1248+
print(f'Serving coz viewer at {url}')
1249+
print('Press Ctrl+C to stop the server')
1250+
1251+
# Open browser unless suppressed or headless
1252+
if not args.no_browser and _can_open_browser():
12061253
t1 = threading.Thread(target=open_browser, args=(url,))
12071254
t1.start()
1255+
elif not _can_open_browser():
1256+
print('No display detected. Connect from your browser or use SSH port forwarding:')
1257+
print(f' ssh -L {port}:localhost:{port} user@host')
1258+
1259+
# Self-pipe trick via set_wakeup_fd: Python's C signal handler
1260+
# writes a byte to the pipe, which wakes select() immediately —
1261+
# no dependence on EINTR, kqueue, or Python-level handlers.
1262+
import select as _select
1263+
sig_r, sig_w = os.pipe()
1264+
os.set_blocking(sig_w, False)
1265+
signal.set_wakeup_fd(sig_w)
1266+
signal.signal(signal.SIGINT, lambda s, f: None)
1267+
signal.signal(signal.SIGTERM, lambda s, f: None)
12081268

1209-
try:
1210-
httpd.serve_forever()
1211-
except KeyboardInterrupt:
1212-
print('\nShutting down server...')
1213-
except OSError as e:
1214-
if e.errno == 48 or e.errno == 98: # Address already in use (macOS/Linux)
1215-
sys.stderr.write(f'error: port {port} is already in use. Try --port <number>\n')
1216-
sys.exit(1)
1217-
raise
1269+
try:
1270+
while True:
1271+
ready, _, _ = _select.select([httpd.socket, sig_r], [], [], 1.0)
1272+
for fd in ready:
1273+
if fd == sig_r:
1274+
os.read(sig_r, 256)
1275+
raise StopIteration
1276+
else:
1277+
httpd._handle_request_noblock()
1278+
except StopIteration:
1279+
pass
1280+
finally:
1281+
signal.set_wakeup_fd(-1)
1282+
os.close(sig_r)
1283+
os.close(sig_w)
1284+
print('\nShutting down server...')
12181285

12191286

12201287
# Special format handler for line reference arguments
@@ -1303,6 +1370,10 @@ _plot_parser.add_argument('--verbose', '-v',
13031370
action='store_true', default=False,
13041371
help='Show detailed scatter plots for each source line (with --text)')
13051372

1373+
_plot_parser.add_argument('--no-browser',
1374+
action='store_true', default=False,
1375+
help='Start server without opening a browser')
1376+
13061377
_plot_parser.add_argument('--json', '-j',
13071378
metavar='<output.json>',
13081379
default=None,

0 commit comments

Comments
 (0)