diff --git a/.gitignore b/.gitignore index e634695a5a..955a7910fc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ src/**/*.js.map *.log # MCP servers configuration -.mcp.json \ No newline at end of file +.mcp.json + +# PID files from gateway lifecycle manager +*.pid \ No newline at end of file diff --git a/gateway.sh b/gateway.sh new file mode 100755 index 0000000000..22152ece0d --- /dev/null +++ b/gateway.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# +# Gateway lifecycle manager +# Usage: ./gateway.sh [start|stop|restart|status] +# +# This script manages the Gateway server process, handling restarts automatically +# when the server exits with code 0 (restart requested). +# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PID_FILE="$SCRIPT_DIR/gateway.pid" +WRAPPER_PID_FILE="$SCRIPT_DIR/gateway-wrapper.pid" +LOG_FILE="$SCRIPT_DIR/logs/gateway.log" + +# Ensure logs directory exists +mkdir -p "$SCRIPT_DIR/logs" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[Gateway]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[Gateway]${NC} $1" +} + +error() { + echo -e "${RED}[Gateway]${NC} $1" +} + +# Check if Gateway is running +is_running() { + if [ -f "$PID_FILE" ]; then + local pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + return 0 + fi + fi + return 1 +} + +# Check if wrapper is running +is_wrapper_running() { + if [ -f "$WRAPPER_PID_FILE" ]; then + local pid=$(cat "$WRAPPER_PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + return 0 + fi + fi + return 1 +} + +# Start the Gateway server in a restart loop +start_gateway() { + if is_wrapper_running; then + error "Gateway wrapper is already running (PID: $(cat "$WRAPPER_PID_FILE"))" + return 1 + fi + + if is_running; then + error "Gateway is already running (PID: $(cat "$PID_FILE"))" + return 1 + fi + + log "Starting Gateway..." + + # Parse additional arguments + local dev_mode="" + shift # Remove 'start' from args + while [[ $# -gt 0 ]]; do + case $1 in + --dev) + dev_mode="--dev" + shift + ;; + --passphrase=*) + export GATEWAY_PASSPHRASE="${1#*=}" + shift + ;; + --passphrase) + export GATEWAY_PASSPHRASE="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + # Check for required passphrase (from arg or environment) + if [ -z "$GATEWAY_PASSPHRASE" ]; then + error "Passphrase is required" + error "Usage: $0 start --passphrase=yourpassphrase [--dev]" + error " or: GATEWAY_PASSPHRASE=yourpassphrase $0 start [--dev]" + return 1 + fi + + # Save wrapper PID + echo "$$" > "$WRAPPER_PID_FILE" + + # Handle Ctrl+C gracefully + trap 'log "Received interrupt signal. Stopping..."; rm -f "$WRAPPER_PID_FILE"; exit 130' INT TERM + + # Run in foreground with restart loop + while true; do + log "Starting Gateway server..." + + # Run Gateway + cd "$SCRIPT_DIR" + START_SERVER=true node dist/index.js $dev_mode + exit_code=$? + + if [ $exit_code -eq 0 ]; then + log "Gateway requested restart (exit code 0). Restarting in 2 seconds..." + sleep 2 + else + log "Gateway stopped (exit code $exit_code). Not restarting." + rm -f "$WRAPPER_PID_FILE" + break + fi + done +} + +# Stop the Gateway server +stop_gateway() { + local stopped=0 + + # First, stop the wrapper to prevent restart + if is_wrapper_running; then + local wrapper_pid=$(cat "$WRAPPER_PID_FILE") + log "Stopping Gateway wrapper (PID: $wrapper_pid)..." + kill "$wrapper_pid" 2>/dev/null + rm -f "$WRAPPER_PID_FILE" + stopped=1 + fi + + # Then stop the Gateway process + if is_running; then + local pid=$(cat "$PID_FILE") + log "Stopping Gateway server (PID: $pid)..." + kill "$pid" 2>/dev/null + + # Wait for process to stop + local count=0 + while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do + sleep 1 + count=$((count + 1)) + done + + if kill -0 "$pid" 2>/dev/null; then + warn "Gateway didn't stop gracefully, force killing..." + kill -9 "$pid" 2>/dev/null + fi + + rm -f "$PID_FILE" + stopped=1 + fi + + if [ $stopped -eq 1 ]; then + log "Gateway stopped" + else + warn "Gateway is not running" + fi +} + +# Restart the Gateway server (via API) +restart_gateway() { + if ! is_running; then + warn "Gateway is not running. Starting..." + start_gateway "$@" + return + fi + + log "Requesting Gateway restart via API..." + + # Determine protocol based on dev mode + local protocol="https" + local curl_opts="-k" + + # Try to detect if running in dev mode by checking the process + if ps aux | grep -v grep | grep "node dist/index.js" | grep -q "\-\-dev"; then + protocol="http" + curl_opts="" + fi + + local port=$(grep -E "^port:" "$SCRIPT_DIR/conf/server.yml" 2>/dev/null | awk '{print $2}' || echo "15888") + + curl -s -X POST $curl_opts "$protocol://localhost:$port/restart" > /dev/null + + if [ $? -eq 0 ]; then + log "Restart request sent. Gateway will restart automatically." + else + error "Failed to send restart request. Is Gateway running?" + fi +} + +# Show Gateway status +status_gateway() { + echo "" + if is_wrapper_running; then + log "Wrapper: ${GREEN}running${NC} (PID: $(cat "$WRAPPER_PID_FILE"))" + else + log "Wrapper: ${RED}not running${NC}" + fi + + if is_running; then + local pid=$(cat "$PID_FILE") + log "Gateway: ${GREEN}running${NC} (PID: $pid)" + + # Try to get status from API + local port=$(grep -E "^port:" "$SCRIPT_DIR/conf/server.yml" 2>/dev/null | awk '{print $2}' || echo "15888") + local response=$(curl -s -k "https://localhost:$port/" 2>/dev/null || curl -s "http://localhost:$port/" 2>/dev/null) + + if [ -n "$response" ]; then + log "API: ${GREEN}responding${NC}" + else + warn "API: ${YELLOW}not responding${NC}" + fi + else + log "Gateway: ${RED}not running${NC}" + fi + echo "" +} + +# Show usage +usage() { + echo "" + echo "Gateway Lifecycle Manager" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " start --passphrase= [--dev] Start Gateway server" + echo " stop Stop Gateway server" + echo " restart Restart Gateway server" + echo " status Show Gateway status" + echo "" + echo "Options:" + echo " --passphrase= Passphrase for wallet encryption (required for start)" + echo " --dev Run in HTTP mode (development)" + echo "" + echo "Examples:" + echo " $0 start --passphrase=mypassword" + echo " $0 start --passphrase=mypassword --dev" + echo " $0 stop" + echo " $0 status" + echo "" +} + +# Main +case "${1:-}" in + start) + start_gateway "$@" + ;; + stop) + stop_gateway + ;; + restart) + restart_gateway "$@" + ;; + status) + status_gateway + ;; + *) + usage + exit 1 + ;; +esac diff --git a/package.json b/package.json index c27f0e5a08..307df19f6f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "lint": "eslint src test --format table --fix", "setup": "bash ./gateway-setup.sh", "setup:with-defaults": "bash ./gateway-setup.sh --with-defaults", - "start": "START_SERVER=true node dist/index.js", + "start": "bash ./gateway.sh start", + "stop": "bash ./gateway.sh stop", + "status": "bash ./gateway.sh status", "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/*.json' dist && copyfiles -u 1 'src/connectors/pancakeswap-sol/idl/*.json' dist", "test": "GATEWAY_TEST_MODE=dev jest --verbose", "test:clear-cache": "jest --clearCache", diff --git a/src/app.ts b/src/app.ts index ff7460ae29..0f356c0e03 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ // External dependencies -import { spawn } from 'child_process'; import { exec } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import { promisify } from 'util'; import fastifyRateLimit from '@fastify/rate-limit'; @@ -26,6 +27,7 @@ import { raydiumRoutes } from './connectors/raydium/raydium.routes'; import { uniswapRoutes } from './connectors/uniswap/uniswap.routes'; import { getHttpsOptions } from './https'; import { poolRoutes } from './pools/pools.routes'; +import { serverRoutes } from './server/server.routes'; import { ConfigManagerV2 } from './services/config-manager-v2'; import { logger } from './services/logger'; import { quoteCache } from './services/quote-cache'; @@ -47,6 +49,64 @@ const devMode = process.argv.includes('--dev') || process.env.GATEWAY_TEST_MODE // Promisify exec for async/await usage const execPromise = promisify(exec); +// PID file management for tracking the official Gateway process +const PID_FILE = path.join(process.cwd(), 'gateway.pid'); + +function writePidFile(): void { + try { + fs.writeFileSync(PID_FILE, process.pid.toString(), 'utf8'); + logger.info(`PID file written: ${PID_FILE} (PID: ${process.pid})`); + } catch (error) { + logger.warn(`Failed to write PID file: ${error}`); + } +} + +function readPidFile(): number | null { + try { + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); + return isNaN(pid) ? null : pid; + } + } catch (error) { + logger.warn(`Failed to read PID file: ${error}`); + } + return null; +} + +function removePidFile(): void { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE); + logger.info('PID file removed'); + } + } catch (error) { + logger.warn(`Failed to remove PID file: ${error}`); + } +} + +async function killExistingGateway(): Promise { + const existingPid = readPidFile(); + if (existingPid && existingPid !== process.pid) { + logger.info(`Found existing Gateway process (PID: ${existingPid}), killing...`); + try { + process.kill(existingPid, 'SIGTERM'); + // Wait a bit for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Force kill if still running + try { + process.kill(existingPid, 0); // Check if process exists + process.kill(existingPid, 'SIGKILL'); + logger.info(`Force killed existing Gateway (PID: ${existingPid})`); + } catch { + // Process already dead, which is good + } + } catch (error) { + // Process doesn't exist or already dead + logger.info(`Existing Gateway process (PID: ${existingPid}) not running`); + } + } +} + const swaggerOptions = { openapi: { info: { @@ -60,6 +120,8 @@ const swaggerOptions = { }, ], tags: [ + // Server lifecycle + { name: 'server', description: 'Server lifecycle endpoints (health, restart, stop)' }, // Main categories { name: '/config', description: 'System configuration endpoints' }, { name: '/wallet', description: 'Wallet management endpoints' }, @@ -221,6 +283,9 @@ const configureGatewayServer = () => { // Register routes on both servers const registerRoutes = async (app: FastifyInstance) => { + // Register server lifecycle routes (health, status, restart, stop) + app.register(serverRoutes); + // Register system routes app.register(configRoutes, { prefix: '/config' }); @@ -330,22 +395,6 @@ const configureGatewayServer = () => { }); }); - // Health check route (outside registerRoutes, only on main server) - server.get('/', async () => { - return { status: 'ok' }; - }); - - // Restart endpoint (outside registerRoutes, only on main server) - server.post('/restart', async (_req, reply) => { - await reply.status(200).send(); - // Spawn a new instance before exiting - spawn(process.argv[0], process.argv.slice(1), { - detached: true, - stdio: 'inherit', - }); - process.exit(0); - }); - return server; }; @@ -363,7 +412,10 @@ export const startGateway = async () => { logger.info(`🔧 Log level configured as: ${ConfigManagerV2.getInstance().get('server.logLevel') || 'info'}`); try { - // Kill any process using the gateway port + // Kill any existing Gateway process tracked by PID file + await killExistingGateway(); + + // Also kill any process using the gateway port (fallback) try { logger.info(`Checking for processes using port ${port}...`); @@ -416,6 +468,9 @@ export const startGateway = async () => { await gatewayApp.listen({ port, host: '0.0.0.0' }); } + // Write PID file after server successfully starts + writePidFile(); + // Single documentation log after server starts const docsUrl = docsPort ? `http://localhost:${docsPort}` : `${protocol}://localhost:${port}/docs`; @@ -428,6 +483,9 @@ export const startGateway = async () => { const shutdown = async () => { logger.info('Shutting down gracefully...'); + // Remove PID file + removePidFile(); + // Close server await gatewayApp.close(); diff --git a/src/server/server.routes.ts b/src/server/server.routes.ts new file mode 100644 index 0000000000..f3d3a3a551 --- /dev/null +++ b/src/server/server.routes.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { Type } from '@sinclair/typebox'; +import { FastifyInstance } from 'fastify'; + +import { logger } from '../services/logger'; +import { GATEWAY_VERSION } from '../version'; + +const PID_FILE = path.join(process.cwd(), 'gateway.pid'); + +function removePidFile(): void { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE); + logger.info('PID file removed'); + } + } catch (error) { + logger.warn(`Failed to remove PID file: ${error}`); + } +} + +// Detect dev mode +const devMode = process.argv.includes('--dev') || process.env.GATEWAY_TEST_MODE === 'dev'; + +export async function serverRoutes(fastify: FastifyInstance) { + // Health check route + fastify.get( + '/', + { + schema: { + description: 'Health check endpoint', + tags: ['server'], + response: { + 200: Type.Object({ + status: Type.String({ description: 'Server status' }), + }), + }, + }, + }, + async () => { + return { status: 'ok' }; + }, + ); + + // Status endpoint - returns server status info + fastify.get( + '/status', + { + schema: { + description: 'Get Gateway server status information including version, uptime, and mode.', + tags: ['server'], + response: { + 200: Type.Object({ + status: Type.String({ description: 'Server status' }), + version: Type.String({ description: 'Gateway version' }), + uptime: Type.Number({ description: 'Server uptime in seconds' }), + mode: Type.String({ description: 'Running mode (dev/production)' }), + pid: Type.Number({ description: 'Process ID' }), + }), + }, + }, + }, + async () => { + return { + status: 'ok', + version: GATEWAY_VERSION, + uptime: process.uptime(), + mode: devMode ? 'dev' : 'production', + pid: process.pid, + }; + }, + ); + + // Restart endpoint - exits with code 0 so wrapper will restart + fastify.post( + '/restart', + { + schema: { + description: 'Restart the Gateway server. The server will shut down and automatically restart.', + tags: ['server'], + response: { + 200: Type.Object({ + message: Type.String({ description: 'Confirmation message' }), + }), + }, + }, + }, + async (_req, reply) => { + await reply.status(200).send({ message: 'Restarting...' }); + + logger.info('Restart requested - shutting down for restart...'); + removePidFile(); + await fastify.close(); + logger.info('Server closed. Exiting for restart...'); + + // Exit with code 0 - wrapper will restart + process.exit(0); + }, + ); + + // Stop endpoint - exits with code 1 so wrapper will NOT restart + fastify.post( + '/stop', + { + schema: { + description: 'Stop the Gateway server completely. The server will shut down and NOT restart.', + tags: ['server'], + response: { + 200: Type.Object({ + message: Type.String({ description: 'Confirmation message' }), + }), + }, + }, + }, + async (_req, reply) => { + await reply.status(200).send({ message: 'Stopping...' }); + + logger.info('Stop requested - shutting down...'); + removePidFile(); + await fastify.close(); + logger.info('Server closed. Goodbye!'); + + // Exit with code 1 - wrapper will NOT restart + process.exit(1); + }, + ); +}