Skip to content

Commit 2adf85b

Browse files
authored
Merge pull request jxmorris12#123 from mdevolde/server
fix: removed stdout/stderr reading, making it easier to run and close LT
2 parents a279924 + aa388c8 commit 2adf85b

File tree

2 files changed

+85
-129
lines changed

2 files changed

+85
-129
lines changed

language_tool_python/server.py

Lines changed: 82 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""LanguageTool server management module."""
22

33
import atexit
4+
import contextlib
45
import http.client
56
import json
67
import os
7-
import re
8+
import random
89
import socket
910
import subprocess
10-
import threading
11+
import time
1112
import urllib.parse
1213
from typing import Any, Dict, List, Optional, Set
1314

@@ -42,6 +43,20 @@
4243
RUNNING_SERVER_PROCESSES: List[subprocess.Popen] = []
4344

4445

46+
def _kill_processes(processes: List[subprocess.Popen]) -> None:
47+
"""
48+
Kill all running server processes.
49+
This function iterates over the list of running server processes and
50+
forcefully kills each process by its PID.
51+
52+
:param processes: A list of subprocess.Popen objects representing the running server processes.
53+
:type processes: List[subprocess.Popen]
54+
"""
55+
for pid in [p.pid for p in processes]:
56+
with contextlib.suppress(psutil.NoSuchProcess):
57+
kill_process_force(pid=pid)
58+
59+
4560
class LanguageTool:
4661
"""
4762
A class to interact with the LanguageTool server for text checking and correction.
@@ -64,21 +79,17 @@ class LanguageTool:
6479
:type language_tool_download_version: Optional[str]
6580
6681
Attributes:
67-
_MIN_PORT (int): The minimum port number to use for the server.
68-
_MAX_PORT (int): The maximum port number to use for the server.
82+
_AVAILABLE_PORTS (List[int]): A list of available ports for the server, shuffled randomly.
6983
_TIMEOUT (int): The timeout for server requests.
7084
_remote (bool): A flag to indicate if the server is remote.
7185
_port (int): The port number to use for the server.
7286
_server (subprocess.Popen): The server process.
73-
_consumer_thread (threading.Thread): The thread to consume server output.
74-
_PORT_RE (re.Pattern): A compiled regular expression pattern to match the server port.
7587
language_tool_download_version (str): The version of LanguageTool to download.
7688
_new_spellings (List[str]): A list of new spellings to register.
7789
_new_spellings_persist (bool): A flag to indicate if new spellings should persist.
7890
_host (str): The host to use for the server.
7991
config (LanguageToolConfig): The configuration to use for the server.
8092
_url (str): The URL of the server if remote.
81-
_stop_consume_event (threading.Event): An event to signal the consumer thread to stop.
8293
motherTongue (str): The user's mother tongue (used in requests to the server).
8394
disabled_rules (Set[str]): A set of disabled rules (used in requests to the server).
8495
enabled_rules (Set[str]): A set of enabled rules (used in requests to the server).
@@ -91,15 +102,6 @@ class LanguageTool:
91102
_spell_checking_categories (Set[str]): A set of spell-checking categories.
92103
"""
93104

94-
_MIN_PORT = 8081
95-
_MAX_PORT = 8999
96-
_TIMEOUT = 5 * 60
97-
_remote = False
98-
_port = _MIN_PORT
99-
_server: subprocess.Popen = None
100-
_consumer_thread: threading.Thread = None
101-
_PORT_RE = re.compile(r"(?:https?://.*:|port\s+)(\d+)", re.I)
102-
103105
def __init__(
104106
self,
105107
language=None,
@@ -114,10 +116,16 @@ def __init__(
114116
"""
115117
Initialize the LanguageTool server.
116118
"""
119+
self._remote = False
120+
self._TIMEOUT = 5 * 60
117121
self.language_tool_download_version = language_tool_download_version
118122
self._new_spellings = None
119123
self._new_spellings_persist = new_spellings_persist
120124
self._host = host or socket.gethostbyname("localhost")
125+
self._AVAILABLE_PORTS = list(range(8081, 8999))
126+
random.shuffle(self._AVAILABLE_PORTS)
127+
self._port = self._AVAILABLE_PORTS.pop()
128+
self._server: subprocess.Popen = None
121129

122130
if remote_server and config is not None:
123131
raise ValueError("Cannot use both remote_server and config parameters.")
@@ -129,7 +137,7 @@ def __init__(
129137
self._url = urllib.parse.urljoin(self._url, "v2/")
130138
self._update_remote_server_config(self._url)
131139
elif not self._server_is_alive():
132-
self._stop_consume_event = threading.Event()
140+
self._url = f"http://{self._host}:{self._port}/v2/"
133141
self._start_server_on_free_port()
134142
if language is None:
135143
try:
@@ -541,13 +549,12 @@ def _start_server_on_free_port(self) -> None:
541549
:raises ServerError: If the server cannot be started and the maximum port number is reached.
542550
"""
543551
while True:
544-
self._url = f"http://{self._host}:{self._port}/v2/"
545552
try:
546553
self._start_local_server()
547554
break
548555
except ServerError:
549-
if self._MIN_PORT <= self._port < self._MAX_PORT:
550-
self._port += 1
556+
if len(self._AVAILABLE_PORTS) > 0:
557+
self._port = self._AVAILABLE_PORTS.pop()
551558
else:
552559
raise
553560

@@ -559,18 +566,11 @@ def _start_local_server(self) -> None:
559566
version. It handles the server initialization, including setting up
560567
the server command and managing the server process.
561568
562-
Notes:
563-
- This method uses subprocess to start the server and reads the server
564-
output to determine the port it is running on.
565-
- It also starts a consumer thread to handle the server's stdout.
566-
567569
:raises PathError: If the path to LanguageTool cannot be found.
568-
:raises LanguageToolError: If the server starts on a different port than requested.
569-
:raises ServerError: If the server is already running or cannot be started.
570+
:raises ServerError: If the server fails to start or exits early.
570571
"""
571572
# Before starting local server, download language tool if needed.
572573
download_lt(self.language_tool_download_version)
573-
err = None
574574
try:
575575
if DEBUG_MODE:
576576
if self._port:
@@ -582,64 +582,62 @@ def _start_local_server(self) -> None:
582582
)
583583
server_cmd = get_server_cmd(self._port, self.config)
584584
except PathError as e:
585-
# Can't find path to LanguageTool.
586-
err = e
585+
raise PathError(
586+
"Failed to find LanguageTool. Please ensure it is downloaded correctly.",
587+
) from e
587588
else:
588-
# Need to PIPE all handles: http://bugs.python.org/issue3905
589589
self._server = subprocess.Popen(
590590
server_cmd,
591591
stdin=subprocess.PIPE,
592-
stdout=subprocess.PIPE,
593-
stderr=subprocess.PIPE,
594-
universal_newlines=True,
592+
stdout=subprocess.DEVNULL,
593+
stderr=subprocess.DEVNULL,
594+
text=True,
595595
startupinfo=startupinfo,
596596
)
597597
global RUNNING_SERVER_PROCESSES
598598
RUNNING_SERVER_PROCESSES.append(self._server)
599599

600-
match = None
601-
while True:
602-
line = self._server.stdout.readline()
603-
if not line:
604-
break
605-
match = self._PORT_RE.search(line)
606-
if match:
607-
port = int(match.group(1))
608-
if port != self._port:
609-
raise LanguageToolError(
610-
f"requested port {self._port}, but got {port}",
611-
)
612-
break
613-
if not match:
614-
err_msg = self._terminate_server()
615-
match = self._PORT_RE.search(err_msg)
616-
if not match:
617-
raise LanguageToolError(err_msg)
618-
port = int(match.group(1))
619-
if port != self._port:
620-
raise LanguageToolError(err_msg)
600+
self._wait_for_server_ready()
621601

622-
if self._server:
623-
self._consumer_thread = threading.Thread(
624-
target=lambda: self._consume(self._server.stdout),
625-
)
626-
self._consumer_thread.daemon = True
627-
self._consumer_thread.start()
628-
else:
629-
# Couldn't start the server, so maybe there is already one running.
630-
if err:
631-
raise Exception(err)
602+
if not self._server:
632603
raise ServerError("Server running; don't start a server here.")
633604

634-
def _consume(self, stdout: Any) -> None:
605+
def _wait_for_server_ready(self, timeout: int = 15) -> None:
635606
"""
636-
Continuously reads from the provided stdout until a stop event is set.
607+
Wait for the LanguageTool server to become ready and responsive.
608+
This method polls the server's ``/languages`` endpoint until it responds
609+
successfully or until the timeout is reached. It also monitors the server
610+
process to detect early exits.
637611
638-
:param stdout: The output stream to read from.
639-
:type stdout: Any
612+
:param timeout: Maximum time in seconds to wait for the server to become ready.
613+
Defaults to 15 seconds.
614+
:type timeout: int
615+
:raises ServerError: If the server process exits early with a non-zero code,
616+
or if the server does not become ready within the specified
617+
timeout period.
640618
"""
641-
while not self._stop_consume_event.is_set() and stdout.readline():
642-
pass
619+
url = urllib.parse.urljoin(self._url, "languages")
620+
start = time.time()
621+
622+
while time.time() - start < timeout:
623+
# Early exit check
624+
ret = self._server.poll()
625+
if ret is not None:
626+
raise ServerError(f"LanguageTool server exited early with code {ret}")
627+
628+
# Attempt to connect
629+
with contextlib.suppress(requests.RequestException):
630+
r = requests.get(url, timeout=2)
631+
if r.ok:
632+
return
633+
634+
time.sleep(0.2)
635+
636+
# Timeout without response
637+
raise ServerError(
638+
f"LanguageTool server did not become ready on {self._host}:{self._port} "
639+
f"within {timeout} seconds"
640+
)
643641

644642
def _server_is_alive(self) -> bool:
645643
"""
@@ -651,65 +649,22 @@ def _server_is_alive(self) -> bool:
651649
"""
652650
return self._server and self._server.poll() is None
653651

654-
def _terminate_server(self) -> str:
652+
def _terminate_server(self) -> None:
655653
"""
656-
Terminates the server process and associated consumer thread.
654+
Terminates the server process.
657655
This method performs the following steps:
658-
1. Signals the consumer thread to stop consuming stdout.
659-
2. Waits for the consumer thread to finish.
660-
3. Attempts to terminate the server process gracefully.
661-
4. If the server process does not terminate within the timeout, force kills it.
662-
5. Closes all associated file descriptors (stdin, stdout, stderr).
663-
6. Captures any error messages from stderr, if available.
664-
665-
:return: Error message from stderr, if any, for further logging or debugging.
666-
:rtype: str
656+
1. Attempts to terminate the server process gracefully.
657+
2. Closes associated file descriptor (stdin).
667658
"""
668-
# Signal the consumer thread to stop consuming stdout
669-
self._stop_consume_event.set()
670-
if self._consumer_thread:
671-
# Wait for the consumer thread to finish
672-
self._consumer_thread.join(timeout=5)
673-
674-
error_message = ""
675659
if self._server:
676-
try:
677-
try:
678-
# Get the main server process using psutil
679-
proc = psutil.Process(self._server.pid)
680-
except psutil.NoSuchProcess:
681-
# If the process doesn't exist, set proc to None
682-
proc = None
683-
684-
# Attempt to terminate the process gracefully
685-
self._server.terminate()
686-
# Wait for the process to terminate and capture any stderr output
687-
_, stderr = self._server.communicate(timeout=5)
688-
689-
except subprocess.TimeoutExpired:
690-
# If the process does not terminate within the timeout, force kill it
691-
kill_process_force(proc=proc)
692-
# Capture remaining stderr output after force termination
693-
_, stderr = self._server.communicate()
694-
695-
finally:
696-
# Close all associated file descriptors (stdin, stdout, stderr)
697-
if self._server.stdin:
698-
self._server.stdin.close()
699-
if self._server.stdout:
700-
self._server.stdout.close()
701-
if self._server.stderr:
702-
self._server.stderr.close()
703-
704-
# Release the server process object
705-
self._server = None
706-
707-
# Capture any error messages from stderr, if available
708-
if stderr:
709-
error_message = stderr.strip()
710-
711-
# Return the error message, if any, for further logging or debugging
712-
return error_message
660+
_kill_processes([self._server])
661+
RUNNING_SERVER_PROCESSES.remove(self._server)
662+
663+
if self._server.stdin:
664+
self._server.stdin.close()
665+
666+
# Release the server process object
667+
self._server = None
713668

714669

715670
class LanguageToolPublicAPI(LanguageTool):
@@ -738,5 +693,4 @@ def terminate_server() -> None:
738693
This function iterates over the list of running server processes and
739694
forcefully kills each process by its PID.
740695
"""
741-
for pid in [p.pid for p in RUNNING_SERVER_PROCESSES]:
742-
kill_process_force(pid=pid)
696+
_kill_processes(RUNNING_SERVER_PROCESSES)

tests/test_major_functionality.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from language_tool_python.exceptions import LanguageToolError, RateLimitError
88

9-
# THESE TESTS ARE SUPPOSED TO BE RUN WITH 6.7-SNAPSHOT VERSION OF LT SERVER
9+
# THESE TESTS ARE SUPPOSED TO BE RUN WITH 6.8-SNAPSHOT VERSION OF LT SERVER
1010

1111

1212
def test_langtool_load():
@@ -101,6 +101,7 @@ def test_process_starts_and_stops_in_context_manager():
101101
proc: subprocess.Popen = tool._server
102102
# Make sure process is running before killing language tool object.
103103
assert proc.poll() is None, "tool._server not running after creation"
104+
time.sleep(0.5) # Give some time for process to stop after context manager exit.
104105
# Make sure process stopped after close() was called.
105106
assert proc.poll() is not None, "tool._server should stop running after deletion"
106107

@@ -115,6 +116,7 @@ def test_process_starts_and_stops_on_close():
115116
tool.close() # Explicitly close() object so process stops before garbage collection.
116117
del tool
117118
# Make sure process stopped after close() was called.
119+
time.sleep(0.5) # Give some time for process to stop after close() call.
118120
assert proc.poll() is not None, "tool._server should stop running after deletion"
119121
# remember --> if poll is None: # p.subprocess is alive
120122

0 commit comments

Comments
 (0)