438 lines
16 KiB
Python
Executable file
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)
|