Skip to content

Commit 25df826

Browse files
committed
fix: Resolve process cleanup issue on Windows in _terminate_server
1 parent bd37753 commit 25df826

File tree

3 files changed

+89
-37
lines changed

3 files changed

+89
-37
lines changed

language_tool_python/server.py

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import subprocess
1111
import threading
1212
import urllib.parse
13+
import psutil
1314

1415
from .config_file import LanguageToolConfig
1516
from .download_lt import download_lt, LTP_DOWNLOAD_VERSION
@@ -20,7 +21,8 @@
2021
parse_url, get_locale_language,
2122
get_language_tool_directory, get_server_cmd,
2223
FAILSAFE_LANGUAGE, startupinfo,
23-
LanguageToolError, ServerError, PathError
24+
LanguageToolError, ServerError, PathError,
25+
kill_process_force
2426
)
2527

2628

@@ -67,6 +69,7 @@ def __init__(
6769
self._url = urllib.parse.urljoin(self._url, 'v2/')
6870
self._update_remote_server_config(self._url)
6971
elif not self._server_is_alive():
72+
self._stop_consume_event = threading.Event()
7073
self._start_server_on_free_port()
7174
if language is None:
7275
try:
@@ -334,7 +337,7 @@ def _start_local_server(self):
334337

335338
if self._server:
336339
self._consumer_thread = threading.Thread(
337-
target=lambda: _consume(self._server.stdout))
340+
target=lambda: self._consume(self._server.stdout))
338341
self._consumer_thread.daemon = True
339342
self._consumer_thread.start()
340343
else:
@@ -345,34 +348,70 @@ def _start_local_server(self):
345348
raise ServerError(
346349
'Server running; don\'t start a server here.'
347350
)
351+
352+
def _consume(self, stdout):
353+
"""Consume/ignore the rest of the server output.
354+
Without this, the server will end up hanging due to the buffer
355+
filling up.
356+
"""
357+
while not self._stop_consume_event.is_set() and stdout.readline():
358+
pass
359+
348360

349361
def _server_is_alive(self):
350362
return self._server and self._server.poll() is None
351363

352364
def _terminate_server(self):
353-
LanguageToolError_message = ''
354-
try:
355-
self._server.terminate()
356-
except OSError:
357-
pass
358-
try:
359-
LanguageToolError_message = self._server.communicate()[1].strip()
360-
except (IOError, ValueError):
361-
pass
362-
try:
363-
self._server.stdout.close()
364-
except IOError:
365-
pass
366-
try:
367-
self._server.stdin.close()
368-
except IOError:
369-
pass
370-
try:
371-
self._server.stderr.close()
372-
except IOError:
373-
pass
374-
self._server = None
375-
return LanguageToolError_message
365+
"""
366+
Terminate the LanguageTool server process and its associated resources.
367+
This method ensures the server process and any associated threads or child processes
368+
are properly terminated and cleaned up.
369+
"""
370+
# Signal the consumer thread to stop consuming stdout
371+
self._stop_consume_event.set()
372+
if self._consumer_thread:
373+
# Wait for the consumer thread to finish
374+
self._consumer_thread.join(timeout=5)
375+
376+
error_message = ''
377+
if self._server:
378+
try:
379+
try:
380+
# Get the main server process using psutil
381+
proc = psutil.Process(self._server.pid)
382+
except psutil.NoSuchProcess:
383+
# If the process doesn't exist, set proc to None
384+
proc = None
385+
386+
# Attempt to terminate the process gracefully
387+
self._server.terminate()
388+
# Wait for the process to terminate and capture any stderr output
389+
_, stderr = self._server.communicate(timeout=5)
390+
391+
except subprocess.TimeoutExpired:
392+
# If the process does not terminate within the timeout, force kill it
393+
kill_process_force(proc=proc)
394+
# Capture remaining stderr output after force termination
395+
_, stderr = self._server.communicate()
396+
397+
finally:
398+
# Close all associated file descriptors (stdin, stdout, stderr)
399+
if self._server.stdin:
400+
self._server.stdin.close()
401+
if self._server.stdout:
402+
self._server.stdout.close()
403+
if self._server.stderr:
404+
self._server.stderr.close()
405+
406+
# Release the server process object
407+
self._server = None
408+
409+
# Capture any error messages from stderr, if available
410+
if stderr:
411+
error_message = stderr.strip()
412+
413+
# Return the error message, if any, for further logging or debugging
414+
return error_message
376415

377416

378417
class LanguageToolPublicAPI(LanguageTool):
@@ -386,14 +425,5 @@ def __init__(self, *args, **kwargs):
386425
@atexit.register
387426
def terminate_server():
388427
"""Terminate the server."""
389-
for proc in RUNNING_SERVER_PROCESSES:
390-
proc.terminate()
391-
392-
393-
def _consume(stdout):
394-
"""Consume/ignore the rest of the server output.
395-
Without this, the server will end up hanging due to the buffer
396-
filling up.
397-
"""
398-
while stdout.readline():
399-
pass
428+
for pid in [p.pid for p in RUNNING_SERVER_PROCESSES]:
429+
kill_process_force(pid=pid)

language_tool_python/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import subprocess
77
import urllib.parse
88
import urllib.request
9+
import psutil
910

1011
from .config_file import LanguageToolConfig
1112
from .match import Match
@@ -177,3 +178,23 @@ def get_jar_info() -> Tuple[str, str]:
177178
def get_locale_language():
178179
"""Get the language code for the current locale setting."""
179180
return locale.getlocale()[0] or locale.getdefaultlocale()[0]
181+
182+
183+
def kill_process_force(*, pid=None, proc=None):
184+
"""Kill a process and its children forcefully.
185+
Usefin when the process is unresponsive, particulary on Windows.
186+
Using psutil is more reliable than killing the process with subprocess."""
187+
assert any([pid, proc]), "Must pass either pid or proc"
188+
try:
189+
proc = psutil.Process(pid) if proc is None else proc
190+
except psutil.NoSuchProcess:
191+
return
192+
for child in proc.children(recursive=True):
193+
try:
194+
child.kill()
195+
except psutil.NoSuchProcess:
196+
pass
197+
try:
198+
proc.kill()
199+
except psutil.NoSuchProcess:
200+
pass

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ urls = { Repository = "https://github.com/jxmorris12/language_tool_python.git" }
1212

1313
dependencies = [
1414
"requests",
15-
"tqdm"
15+
"tqdm",
16+
"psutil"
1617
]
1718

1819
[project.optional-dependencies]

0 commit comments

Comments
 (0)