File size: 5,859 Bytes
3fc8e60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | #!/usr/bin/env bash
# Start / stop / check the local Lexora services.
#
# Identifying our own processes is harder than it looks, and getting it wrong is
# dangerous. Three projects share this machine and at least two of them run
# `python -m uvicorn app.main:app --port N`, so the command line does NOT identify
# the owner -- only the working directory does. An earlier version of this script
# matched on the command line alone, which meant `stop` could have killed a
# neighbouring project's API, and `status` did in fact report a neighbour's server
# as "Lexora, up" while Lexora was not running at all.
#
# So: a pid is ours only if its working directory is inside this repo. Every
# lookup below goes through that check, and `start` refuses to touch a port held
# by anyone else rather than pretending it is already running.
#
# Ports are deliberately non-default -- 7860, 7861 and 3000 belong to the other
# two projects on this machine.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API_PORT="${LEXORA_API_PORT:-7862}"
WEB_PORT="${LEXORA_WEB_PORT:-3020}"
API_DIR="$ROOT/apps/api"
WEB_DIR="$ROOT/apps/web"
# Every lsof call below ends in `|| true`. lsof exits non-zero when it finds nothing,
# which is the *normal* answer to "is this port free?" -- and under `set -euo pipefail`
# that non-zero status propagates out of the command substitution and aborts the whole
# script. That bug shipped once: `stop` killed the first service, hit a free port on the
# second lookup, and died silently, leaving a process running that it had just reported
# as stopped.
# The working directory of a pid, or empty when it cannot be read.
pid_cwd() { lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1 || true; }
# True only when the pid is running from inside this repo.
is_ours() {
local cwd; cwd="$(pid_cwd "$1")"
[ -n "$cwd" ] && [ "${cwd#"$ROOT"}" != "$cwd" ]
}
# Whoever is listening on a port, ours or not. Empty when the port is free.
port_pid() { lsof -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null | head -1 || true; }
# Our pid on a port. Empty when the port is free *or* held by another project --
# the two cases are distinguished by the caller, never conflated.
our_pid() {
local pid; pid="$(port_pid "$1")"
if [ -n "$pid" ] && is_ours "$pid"; then echo "$pid"; fi
}
api_pid() { our_pid "$API_PORT"; }
web_pid() { our_pid "$WEB_PORT"; }
# Name whoever is squatting on a port we wanted, and decline to interfere.
report_foreign() {
local port="$1" var="$2" pid; pid="$(port_pid "$port")"
[ -z "$pid" ] && return 1
echo " port $port is held by pid $pid running from $(pid_cwd "$pid")" >&2
echo " that is not this repo, so it will not be touched." >&2
echo " set LEXORA_${var}_PORT=<free port> and retry." >&2
return 0
}
wait_for() {
local url="$1" name="$2"
for _ in $(seq 1 90); do
if curl -sf "$url" >/dev/null 2>&1; then echo " $name ready"; return 0; fi
sleep 1
done
echo " $name did NOT come up -- see /tmp/lexora_$name.log" >&2
return 1
}
start_api() {
if [ -n "$(api_pid)" ]; then echo " api already running (pid $(api_pid))"; return 0; fi
if report_foreign "$API_PORT" API; then return 1; fi
cd "$ROOT"
PYTHONPATH="$API_DIR" nohup "$API_DIR/.venv/bin/python" -m uvicorn app.main:app \
--host 127.0.0.1 --port "$API_PORT" --log-level warning \
> /tmp/lexora_api.log 2>&1 &
wait_for "http://127.0.0.1:$API_PORT/api/health" "api"
}
start_web() {
if [ -n "$(web_pid)" ]; then echo " web already running (pid $(web_pid))"; return 0; fi
if report_foreign "$WEB_PORT" WEB; then return 1; fi
cd "$WEB_DIR"
# Pass the API origin explicitly so the two ports can never drift apart.
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://127.0.0.1:$API_PORT}" \
nohup npm run dev > /tmp/lexora_web.log 2>&1 &
wait_for "http://127.0.0.1:$WEB_PORT" "web"
}
# Signals only pids that passed is_ours(), and confirms they are gone.
#
# "Signal sent" is not "process stopped". The embedded Qdrant store allows a single
# writer, so an API that outlives its own `stop` keeps the lock and makes the whole
# integration suite skip -- which is exactly what happened before this waited.
kill_pid() {
local pid="$1" name="$2"
kill "$pid" 2>/dev/null || { echo " $name (pid $pid) already gone"; return 0; }
for _ in $(seq 1 100); do # up to 10s for a graceful exit
kill -0 "$pid" 2>/dev/null || { echo " $name stopped (pid $pid)"; return 0; }
sleep 0.1
done
echo " $name (pid $pid) ignored SIGTERM; sending SIGKILL" >&2
kill -9 "$pid" 2>/dev/null || true
for _ in $(seq 1 20); do
kill -0 "$pid" 2>/dev/null || { echo " $name killed (pid $pid)"; return 0; }
sleep 0.1
done
echo " $name (pid $pid) SURVIVED SIGKILL -- investigate before running tests" >&2
return 1
}
stop_all() {
local api web rc=0
api="$(api_pid)"; web="$(web_pid)"
[ -n "$api" ] && { kill_pid "$api" api || rc=1; }
[ -n "$web" ] && { kill_pid "$web" web || rc=1; }
[ -z "$api$web" ] && echo " nothing of ours was running"
echo " nothing outside $ROOT was signalled"
return $rc
}
show() {
local name="$1" port="$2" mine foreign
mine="$(our_pid "$port")"
foreign="$(port_pid "$port")"
if [ -n "$mine" ]; then
echo " $name up pid $mine :$port"
elif [ -n "$foreign" ]; then
echo " $name FOREIGN pid $foreign :$port <- $(pid_cwd "$foreign")"
else
echo " $name down :$port"
fi
}
case "${1:-start}" in
start) echo "starting Lexora"; start_api; start_web ;;
api) start_api ;;
web) start_web ;;
stop) stop_all ;;
status)
show api "$API_PORT"
show web "$WEB_PORT"
curl -sf "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null | head -c 200 || true
echo
;;
*) echo "usage: scripts/dev.sh {start|stop|status|api|web}" >&2; exit 2 ;;
esac
|