Skip to content

Commit 78feda4

Browse files
committed
ref: move logging and subprocess management into _RunningServerProcess class
1 parent 8004599 commit 78feda4

File tree

2 files changed

+60
-28
lines changed

2 files changed

+60
-28
lines changed

src/llama_cpp_server_python/_server.py

+50-27
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,12 @@ def __init__(
8989
self.parallel = parallel
9090
self.cont_batching = cont_batching
9191

92-
self._logging_threads = []
93-
self._status = "stopped"
94-
self.process = None
9592
self._check_resources()
9693

9794
if logger is None:
9895
logger = logging.getLogger(__name__ + ".Server" + str(self.port))
99-
self.logger = logger
96+
self._logger = logger
97+
self._process = None
10098

10199
@classmethod
102100
def from_huggingface(
@@ -135,9 +133,15 @@ def base_url(self) -> str:
135133
return f"http://127.0.0.1:{self.port}"
136134

137135
@property
138-
def status(self) -> str:
139-
"""The status of the server: 'stopped', 'starting', or 'running'."""
140-
return self._status
136+
def logger(self) -> logging.Logger:
137+
"""The logger used for server output."""
138+
return self._logger
139+
140+
@logger.setter
141+
def logger(self, logger: logging.Logger):
142+
self._logger = logger
143+
if self._process is not None:
144+
self._process.logger = logger
141145

142146
def start(self) -> None:
143147
"""Start the server in a subprocess.
@@ -151,32 +155,31 @@ def start(self) -> None:
151155
152156
You can start and stop the server multiple times in a row.
153157
"""
154-
if self.process is not None:
158+
if self._process is not None:
155159
raise RuntimeError("Server is already running.")
156160
self._check_resources()
157161
self.logger.info(
158162
f"Starting server with command: '{' '.join(self._command)}'..."
159163
)
160-
self.process = subprocess.Popen(
161-
self._command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
162-
)
163-
self._status = "starting"
164-
self._logging_threads = self._watch_outputs()
164+
self._process = _RunningServerProcess(self._command, self.logger)
165165

166166
def stop(self) -> None:
167167
"""Terminate the server subprocess. No-op if there is no active subprocess."""
168-
if self.process is None:
168+
if self._process is None:
169169
return
170-
self.process.kill()
171-
for thread in self._logging_threads:
172-
thread.join()
173-
self._status = "stopped"
174-
self.process = None
170+
self._process.stop()
171+
self._process = None
172+
173+
def wait_for_ready(self, *, timeout: int = 5) -> None:
174+
"""Wait until the server is ready to receive requests."""
175+
if self._process is None:
176+
raise RuntimeError("Server is not running.")
177+
self._process.wait_for_ready(timeout=timeout)
175178

176179
def __enter__(self):
177180
"""Start the server when entering a context manager."""
178181
self.start()
179-
self.wait_for_ready()
182+
self._process.wait_for_ready()
180183
return self
181184

182185
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -200,22 +203,37 @@ def _check_resources(self) -> None:
200203
if not self.model_path.exists():
201204
raise FileNotFoundError(f"Model weights not found at {self.model_path}.")
202205

206+
207+
class _RunningServerProcess:
208+
def __init__(self, args: list[str], logger: logging.Logger) -> None:
209+
self.popen = subprocess.Popen(
210+
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
211+
)
212+
self.logger = logger
213+
self._logging_threads = self._watch_outputs()
214+
self._status = "starting"
215+
203216
def wait_for_ready(self, *, timeout: int = 5) -> None:
204-
"""Wait until the server is ready to receive requests."""
205217
if self._status == "running":
206218
return
207219
start = time.time()
208220
while time.time() - start < timeout:
209-
if self.process.poll() is not None:
210-
raise RuntimeError(
211-
f"Server exited unexpectedly with code {self.process.returncode}."
212-
)
221+
self._check_not_exited()
213222
if self._status == "running":
214223
self.logger.info("Server started.")
215224
return
216225
time.sleep(0.1)
217226
raise TimeoutError(f"Server did not start within {timeout} seconds.")
218227

228+
def _check_not_exited(self) -> None:
229+
exit_code = self.popen.poll()
230+
if exit_code is None:
231+
return
232+
self.stop()
233+
raise RuntimeError(
234+
f"Server exited unexpectedly with code {self.popen.returncode}."
235+
)
236+
219237
def _watch_outputs(self) -> list[threading.Thread]:
220238
def watch(file: io.StringIO):
221239
for line in file:
@@ -224,8 +242,13 @@ def watch(file: io.StringIO):
224242
self._status = "running"
225243
self.logger.info(line)
226244

227-
std_out_thread = threading.Thread(target=watch, args=(self.process.stdout,))
228-
std_err_thread = threading.Thread(target=watch, args=(self.process.stderr,))
245+
std_out_thread = threading.Thread(target=watch, args=(self.popen.stdout,))
246+
std_err_thread = threading.Thread(target=watch, args=(self.popen.stderr,))
229247
std_out_thread.start()
230248
std_err_thread.start()
231249
return [std_out_thread, std_err_thread]
250+
251+
def stop(self):
252+
self.popen.kill()
253+
for thread in self._logging_threads:
254+
thread.join()

tests/test_server.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,21 @@ def test_no_resources(binary_path, model_path):
7070
Server(binary_path="bad_path", model_path=model_path)
7171

7272

73+
def test_bad_binary(tmp_path: Path, model_path):
74+
binary_path = tmp_path / "bad_server"
75+
binary_path.write_text("bad server")
76+
server = Server(binary_path=binary_path, model_path=model_path)
77+
with pytest.raises(PermissionError):
78+
server.start()
79+
80+
7381
def test_bad_model(tmp_path: Path, binary_path):
7482
model_path = tmp_path / "bad_model.gguf"
7583
model_path.write_text("bad model")
7684
server = Server(binary_path=binary_path, model_path=model_path)
85+
server.start()
7786
with pytest.raises(RuntimeError):
78-
server.start()
87+
server.wait_for_ready()
7988

8089

8190
def test_from_huggingface(tmp_path):

0 commit comments

Comments
 (0)