ZDDC/dev-server
2026-06-11 13:32:31 -05:00

438 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Development HTTP server with cache-busting headers
Supports start, status, and stop commands for process management.
"""
import argparse
import os
import signal
import socketserver
import sys
import time
import urllib.request
import urllib.error
from http.server import SimpleHTTPRequestHandler
from pathlib import Path
class NoCacheHTTPRequestHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
super().end_headers()
class DevServer:
def __init__(self, port=8000, directory=None):
self.port = port
self.directory = directory or os.getcwd()
self.runtime_dir = self._get_runtime_dir()
# Port-specific PID files to allow multiple servers
self.pidfile = self.runtime_dir / f"zddc-dev-server-{port}.pid"
def _get_runtime_dir(self):
"""Get appropriate runtime directory for PID files"""
# Try user runtime directory first (systemd)
import getpass
uid = os.getuid()
user_runtime = Path(f"/run/user/{uid}")
if user_runtime.exists() and user_runtime.is_dir():
runtime_dir = user_runtime / "zddc"
runtime_dir.mkdir(exist_ok=True)
return runtime_dir
# Fall back to user's cache directory
home = Path.home()
cache_dir = home / ".cache" / "zddc"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_server_pid(self):
"""Get the PID of running server from pidfile"""
pid, _ = self._get_server_info()
return pid
def _get_server_info(self):
"""Get PID and serving directory from pidfile"""
if not self.pidfile.exists():
return None, None
try:
with open(self.pidfile, 'r') as f:
content = f.read().strip()
# Handle different formats: PID, PID:PORT, PID:DIRECTORY
if ':' in content:
pid_str, rest = content.split(':', 1)
pid = int(pid_str)
# If rest is numeric, it's old PID:PORT format
try:
int(rest)
serving_dir = None # Old format, directory unknown
except ValueError:
serving_dir = rest # New PID:DIRECTORY format
else:
pid = int(content)
serving_dir = None # Old format, directory unknown
# Check if process is actually running using /proc
if self._is_process_running(pid):
return pid, serving_dir
else:
# Clean up stale pidfile
print(f" Cleaning up stale PID file for process {pid}")
self._cleanup()
return None, None
except (ValueError, IOError) as e:
print(f" Error reading PID file: {e}")
self._cleanup()
return None, None
def _is_process_running(self, pid):
"""Check if a process with given PID is running and is our process"""
try:
# Check if process exists
with open(f"/proc/{pid}/comm", 'r') as f:
comm = f.read().strip()
# Verify it's a python process
if 'python' not in comm:
return False
# Check command line to verify it's our dev-server
try:
with open(f"/proc/{pid}/cmdline", 'r') as f:
cmdline = f.read()
return 'dev-server.py' in cmdline
except (FileNotFoundError, PermissionError):
# If we can't read cmdline, assume it's our process if it's python
return True
except (FileNotFoundError, PermissionError):
return False
def is_server_running(self):
"""Check if server is running by making HTTP request"""
try:
with urllib.request.urlopen(f"http://localhost:{self.port}/", timeout=2) as response:
return response.status == 200 or response.status == 403 # 403 for directory listing disabled
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
return False
def start(self, daemon=False):
"""Start the development server"""
# Check if server is already running on this port
existing_pid = self.get_server_pid()
if existing_pid:
print(f"Server is already running (PID: {existing_pid}) at http://localhost:{self.port}")
return True # Exit without error as requested
# Check if port is in use by another process
if self.is_server_running():
print(f"Port {self.port} is already in use by another process")
return False
if daemon:
self._start_daemon()
else:
self._start_foreground()
return True
def _start_foreground(self):
"""Start server in foreground mode"""
print(f"Starting dev server on port {self.port}...")
print(f"Serving directory: {self.directory}")
print("Press Ctrl+C to stop")
# Change to the specified directory
try:
os.chdir(self.directory)
except OSError as e:
print(f"Failed to change to directory {self.directory}: {e}")
return False
# Set up signal handler for graceful shutdown
def signal_handler(signum, frame):
print("\nShutting down server...")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
httpd = socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler)
httpd.serve_forever()
except OSError as e:
if e.errno == 98: # Address already in use
print(f"Port {self.port} is already in use")
else:
print(f"Error starting server: {e}")
return False
except KeyboardInterrupt:
print("\nServer stopped")
return True
except Exception as e:
print(f"Error starting server: {e}")
finally:
self._cleanup()
def _start_daemon(self):
"""Start server in daemon mode (background)"""
try:
# Fork the first time (detach from parent)
pid = os.fork()
if pid > 0:
# Parent process - wait a moment then exit
time.sleep(0.5)
return True
except OSError as e:
print(f"Fork #1 failed: {e}")
return False
# Change to the specified directory before daemonizing
try:
os.chdir(self.directory)
except OSError as e:
print(f"Failed to change to directory {self.directory}: {e}")
return False
# Decouple from parent environment
os.setsid()
os.umask(0)
# Fork the second time (prevent zombie processes)
try:
pid = os.fork()
if pid > 0:
# Parent process - exit immediately
os._exit(0)
except OSError as e:
print(f"Fork #2 failed: {e}")
os._exit(1)
# Redirect standard file descriptors
sys.stdout.flush()
sys.stderr.flush()
with open('/dev/null', 'r') as si:
os.dup2(si.fileno(), sys.stdin.fileno())
with open('/dev/null', 'w') as so:
os.dup2(so.fileno(), sys.stdout.fileno())
with open('/dev/null', 'w') as se:
os.dup2(se.fileno(), sys.stderr.fileno())
# Write PID to file with directory information
try:
with open(self.pidfile, 'w') as f:
f.write(f"{os.getpid()}:{self.directory}")
print(f"Dev server started on port {self.port} serving {self.directory}")
return True
except IOError as e:
print(f"Failed to write PID file: {e}")
return False
# Start the server
try:
with socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler) as httpd:
httpd.serve_forever()
except Exception:
# In daemon mode, errors just cause the process to exit
pass
finally:
self._cleanup()
def status(self):
"""Check server status by testing HTTP connection"""
# Check for stale PID files first
self._cleanup_if_stale()
pid, serving_dir = self._get_server_info()
if pid:
# Test if the server is actually responding on its port
server_responding = self.is_server_running()
print(f"Dev server is running")
print(f" PID: {pid}")
print(f" Port: {self.port}")
print(f" URL: http://localhost:{self.port}")
print(f" Directory: {serving_dir or 'Unknown (old PID file format)'}")
print(f" PID file: {self.pidfile}")
if server_responding:
print(f" Status: Responding to HTTP requests")
else:
print(f" Status: Process running but not responding to HTTP (may be starting up)")
# Get process uptime
try:
uptime_seconds = self._get_process_uptime(pid)
print(f" Uptime: {self._format_uptime(uptime_seconds)}")
except (FileNotFoundError, IndexError, ValueError):
print(f" Uptime: Unable to determine")
return True
else:
print("Dev server is not running")
return False
def _get_process_uptime(self, pid):
"""Get the actual uptime of a process in seconds"""
with open(f"/proc/{pid}/stat", 'r') as f:
stat_data = f.read().split()
starttime_ticks = int(stat_data[21]) # Process start time in ticks since boot
# Get system clock ticks per second
clock_ticks = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
# Get system boot time
with open("/proc/stat", 'r') as f:
for line in f:
if line.startswith('btime '):
boot_time = int(line.split()[1])
break
# Calculate process start time in seconds since epoch
process_start_time = boot_time + (starttime_ticks / clock_ticks)
# Calculate uptime
return time.time() - process_start_time
def _cleanup_if_stale(self):
"""Check for and clean up stale PID files"""
if self.pidfile.exists():
try:
with open(self.pidfile, 'r') as f:
content = f.read().strip()
# Handle different formats: PID, PID:PORT, PID:DIRECTORY
if ':' in content:
pid = int(content.split(':', 1)[0])
else:
pid = int(content)
if not self._is_process_running(pid):
print(f" Found stale PID file for process {pid}, cleaning up")
self._cleanup()
except (ValueError, IOError):
print(f" Found corrupted PID file, cleaning up")
self._cleanup()
def stop(self):
"""Stop the development server"""
pid = self.get_server_pid()
if not pid:
if self.is_server_running():
print("Server is running but not managed by this script")
print("Cannot stop server started by another process")
return False
else:
print("Dev server is not running")
return False
try:
print(f"Stopping dev server (PID: {pid})...")
os.kill(pid, signal.SIGTERM)
# Wait for process to stop
for i in range(30): # Wait up to 3 seconds
time.sleep(0.1)
try:
os.kill(pid, 0) # Test if process still exists
except OSError:
# Process no longer exists
break
else:
# Force kill if still running
print("Process didn't stop gracefully, force killing...")
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass # Process might have died already
self._cleanup()
print("Dev server stopped")
return True
except OSError as e:
print(f"Error stopping server: {e}")
self._cleanup()
return False
def _cleanup(self):
"""Clean up PID file"""
if self.pidfile.exists():
try:
self.pidfile.unlink()
except OSError as e:
print(f"Warning: Could not remove PID file {self.pidfile}: {e}")
def _format_uptime(self, seconds):
"""Format uptime in human readable format"""
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
else:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
return f"{hours}h {minutes}m"
def main():
parser = argparse.ArgumentParser(
description="ZDDC Development Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Commands:
start Start the development server (default)
status Show server status
stop Stop the development server
Examples:
%(prog)s # Start server in foreground (default)
%(prog)s start -d # Start server in background
%(prog)s status # Check if server is running
%(prog)s stop # Stop the server
%(prog)s -p 8080 start # Start on port 8080
%(prog)s start ~/docs # Start serving ~/docs directory
%(prog)s -p 9000 ~/src # Start serving ~/src on port 9000"""
)
parser.add_argument('command', nargs='?', default='start',
choices=['start', 'status', 'stop'],
help='Command to execute (default: start)')
parser.add_argument('-p', '--port', type=int, default=8000,
help='Port to run server on (default: 8000)')
parser.add_argument('-d', '--daemon', action='store_true',
help='Run server in background (daemon mode)')
parser.add_argument('directory', nargs='?', default=None,
help='Directory to serve (default: current directory)')
args = parser.parse_args()
server = DevServer(port=args.port, directory=args.directory)
if args.command == 'start':
if not server.start(daemon=args.daemon):
sys.exit(1)
# After starting (or if already running), show status
if not server.status():
sys.exit(1)
elif args.command == 'status':
if not server.status():
sys.exit(1)
elif args.command == 'stop':
if not server.stop():
sys.exit(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nOperation cancelled")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)