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