Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit cd22910

Browse files
authored
Add dev plugin execution test case (lmstudio-ai#139)
* Forwards dev plugin runner signals to child plugin runner * Actually emits the API stability warning
1 parent 77cd175 commit cd22910

File tree

5 files changed

+225
-14
lines changed

5 files changed

+225
-14
lines changed

.github/workflows/test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ jobs:
4141
max-parallel: 8
4242
matrix:
4343
python-version: ["3.10", "3.11", "3.12", "3.13"]
44-
# There's no platform specific SDK code, but explicitly check Windows
45-
# to ensure there aren't any inadvertent POSIX-only assumptions
44+
# While the main SDK is platform independent, the subprocess execution
45+
# in the plugin runner and tests requires some Windows-specific code
46+
# Note: a green tick in CI is currently misleading due to
47+
# https://github.com/lmstudio-ai/lmstudio-python/issues/140
4648
os: [ubuntu-22.04, windows-2022]
4749

4850
# Check https://github.com/actions/action-versions/tree/main/config/actions

src/lmstudio/plugin/_dev_runner.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Plugin dev client implementation."""
22

33
import asyncio
4+
import io
45
import os
6+
import signal
57
import subprocess
68
import sys
79

810
from contextlib import asynccontextmanager
9-
from pathlib import Path
1011
from functools import partial
11-
from typing import AsyncGenerator, Iterable, TypeAlias
12+
from pathlib import Path
13+
from threading import Event as SyncEvent
14+
from typing import Any, AsyncGenerator, Iterable, TypeAlias
1215

1316
from typing_extensions import (
1417
# Native in 3.11+
@@ -115,6 +118,7 @@ async def register_dev_plugin(self) -> AsyncGenerator[tuple[str, str], None]:
115118
async def _run_plugin_task(
116119
self, result_queue: asyncio.Queue[int], debug: bool = False
117120
) -> None:
121+
notify_subprocess_thread = SyncEvent()
118122
async with self.register_dev_plugin() as (client_id, client_key):
119123
wait_for_subprocess = asyncio.ensure_future(
120124
asyncio.to_thread(
@@ -123,18 +127,20 @@ async def _run_plugin_task(
123127
self._plugin_path,
124128
client_id,
125129
client_key,
126-
debug,
130+
notify_subprocess_thread,
131+
debug=debug,
127132
)
128133
)
129134
)
130135
try:
131136
result = await wait_for_subprocess
132137
except asyncio.CancelledError:
133138
# Likely a Ctrl-C press, which is the expected termination process
139+
notify_subprocess_thread.set()
134140
result_queue.put_nowait(0)
135141
raise
136142
# Subprocess terminated, pass along its return code in the parent process
137-
await result_queue.put(result.returncode)
143+
await result_queue.put(result)
138144

139145
async def run_plugin(
140146
self, *, allow_local_imports: bool = True, debug: bool = False
@@ -149,24 +155,82 @@ async def run_plugin(
149155
return await result_queue.get()
150156

151157

158+
def _get_creation_flags() -> int:
159+
if sys.platform == "win32":
160+
return subprocess.CREATE_NEW_PROCESS_GROUP
161+
return 0
162+
163+
164+
def _start_child_process(
165+
command: list[str], *, text: bool | None = True, **kwds: Any
166+
) -> subprocess.Popen[str]:
167+
creationflags = kwds.pop("creationflags", 0)
168+
creationflags |= _get_creation_flags()
169+
return subprocess.Popen(command, text=text, creationflags=creationflags, **kwds)
170+
171+
172+
def _get_interrupt_signal() -> signal.Signals:
173+
if sys.platform == "win32":
174+
return signal.CTRL_C_EVENT
175+
return signal.SIGINT
176+
177+
178+
_PLUGIN_INTERRUPT_SIGNAL = _get_interrupt_signal()
179+
_PLUGIN_STATUS_POLL_INTERVAL = 1
180+
_PLUGIN_STOP_TIMEOUT = 2
181+
182+
183+
def _interrupt_child_process(process: subprocess.Popen[Any], timeout: float) -> int:
184+
process.send_signal(_PLUGIN_INTERRUPT_SIGNAL)
185+
try:
186+
return process.wait(timeout)
187+
except TimeoutError:
188+
process.kill()
189+
raise
190+
191+
152192
# TODO: support the same source code change monitoring features as `lms dev`
153193
def _run_plugin_in_child_process(
154-
plugin_path: Path, client_id: str, client_key: str, debug: bool = False
155-
) -> subprocess.CompletedProcess[str]:
194+
plugin_path: Path,
195+
client_id: str,
196+
client_key: str,
197+
abort_event: SyncEvent,
198+
*,
199+
debug: bool = False,
200+
) -> int:
156201
env = os.environ.copy()
157202
env[ENV_CLIENT_ID] = client_id
158203
env[ENV_CLIENT_KEY] = client_key
159204
package_name = __spec__.parent
160205
assert package_name is not None
161206
debug_option = ("--debug",) if debug else ()
207+
# If stdout is unbuffered, specify the same in the child process
208+
stdout = sys.__stdout__
209+
unbuffered_arg: tuple[str, ...]
210+
if stdout is None or not isinstance(stdout.buffer, io.BufferedWriter):
211+
unbuffered_arg = ("-u",)
212+
else:
213+
unbuffered_arg = ()
214+
162215
command: list[str] = [
163216
sys.executable,
217+
*unbuffered_arg,
164218
"-m",
165219
package_name,
166220
*debug_option,
167221
os.fspath(plugin_path),
168222
]
169-
return subprocess.run(command, text=True, env=env)
223+
process = _start_child_process(command, env=env)
224+
while True:
225+
result = process.poll()
226+
if result is not None:
227+
print("Child process terminated unexpectedly")
228+
break
229+
if abort_event.wait(_PLUGIN_STATUS_POLL_INTERVAL):
230+
print("Gracefully terminating child process...")
231+
result = _interrupt_child_process(process, _PLUGIN_STOP_TIMEOUT)
232+
break
233+
return result
170234

171235

172236
async def run_plugin_async(

src/lmstudio/plugin/cli.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,29 @@ def main(argv: Sequence[str] | None = None) -> int:
4141
parser.print_usage()
4242
print(f"ERROR: Failed to find plugin folder at {plugin_path!r}")
4343
return 1
44-
warnings.filterwarnings(
45-
"ignore", ".*the plugin API is not yet stable", FutureWarning
46-
)
4744
log_level = logging.DEBUG if args.debug else logging.INFO
4845
logging.basicConfig(level=log_level)
46+
if sys.platform == "win32":
47+
# Accept Ctrl-C events even in non-default process groups
48+
# (allows for graceful termination when Ctrl-C is received
49+
# from a controlling process rather than from a console)
50+
# Based on https://github.com/python/cpython/blob/3.14/Lib/test/win_console_handler.py
51+
# and https://stackoverflow.com/questions/35772001/how-can-i-handle-a-signal-sigint-on-a-windows-os-machine/35792192#35792192
52+
from ctypes import c_void_p, windll, wintypes
53+
54+
SetConsoleCtrlHandler = windll.kernel32.SetConsoleCtrlHandler
55+
SetConsoleCtrlHandler.argtypes = (c_void_p, wintypes.BOOL)
56+
SetConsoleCtrlHandler.restype = wintypes.BOOL
57+
if not SetConsoleCtrlHandler(None, 0):
58+
print("Failed to enable Ctrl-C events, termination may be abrupt")
4959
if not args.dev:
60+
warnings.filterwarnings(
61+
"ignore", ".*the plugin API is not yet stable", FutureWarning
62+
)
5063
try:
5164
runner.run_plugin(plugin_path, allow_local_imports=True)
5265
except KeyboardInterrupt:
53-
print("Plugin execution terminated with Ctrl-C")
66+
print("Plugin execution terminated by console interrupt", flush=True)
5467
else:
5568
# Retrieve args from API host, spawn plugin in subprocess
5669
try:

src/lmstudio/plugin/runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,9 @@ async def run_plugin(self, *, allow_local_imports: bool = False) -> int:
228228
await asyncio.gather(*(e.wait() for e in hook_ready_events))
229229
await self.plugins.remote_call("pluginInitCompleted")
230230
# Indicate that prompt processing is ready
231-
print(f"Plugin {plugin!r} running, press Ctrl-C to terminate...")
231+
print(
232+
f"Plugin {plugin!r} running, press Ctrl-C to terminate...", flush=True
233+
)
232234
# Task group will wait for the plugins to run
233235
return 0
234236

tests/test_plugin_examples.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Test plugin examples can run as dev plugins."""
2+
3+
import subprocess
4+
import sys
5+
import time
6+
7+
8+
from pathlib import Path
9+
from queue import Empty, Queue
10+
from threading import Thread
11+
from typing import Iterable, TextIO
12+
13+
import pytest
14+
15+
from lmstudio.plugin._dev_runner import (
16+
_interrupt_child_process,
17+
_start_child_process,
18+
_PLUGIN_STOP_TIMEOUT,
19+
)
20+
from lmstudio.plugin.runner import _PLUGIN_API_STABILITY_WARNING
21+
22+
23+
_THIS_DIR = Path(__file__).parent.resolve()
24+
_PLUGIN_EXAMPLES_DIR = (_THIS_DIR / "../examples/plugins").resolve()
25+
26+
27+
def _get_plugin_paths() -> list[Path]:
28+
return [p for p in _PLUGIN_EXAMPLES_DIR.iterdir() if p.is_dir()]
29+
30+
31+
def _monitor_stream(stream: TextIO, queue: Queue[str], *, debug: bool = False) -> None:
32+
for line in stream:
33+
if debug:
34+
print(line)
35+
queue.put(line)
36+
37+
38+
def _drain_queue(queue: Queue[str]) -> Iterable[str]:
39+
while True:
40+
try:
41+
yield queue.get(block=False)
42+
except Empty:
43+
break
44+
45+
46+
def _exec_plugin(plugin_path: Path) -> subprocess.Popen[str]:
47+
# Run plugin in dev mode with IO pipes line buffered
48+
# (as the test process is monitoring for specific output)
49+
cmd = [
50+
sys.executable,
51+
"-u",
52+
"-m",
53+
"lmstudio.plugin",
54+
"--dev",
55+
str(plugin_path),
56+
]
57+
return _start_child_process(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
58+
59+
60+
_PLUGIN_START_TIMEOUT = 5
61+
62+
63+
def _exec_and_interrupt(plugin_path: Path) -> tuple[list[str], list[str], list[str]]:
64+
# Start the plugin in a child process
65+
process = _exec_plugin(plugin_path)
66+
# Ensure pipes don't fill up and block subprocess execution
67+
stdout_q: Queue[str] = Queue()
68+
stdout_thread = Thread(target=_monitor_stream, args=[process.stdout, stdout_q])
69+
stdout_thread.start()
70+
stderr_q: Queue[str] = Queue()
71+
stderr_thread = Thread(target=_monitor_stream, args=[process.stderr, stderr_q])
72+
stderr_thread.start()
73+
startup_lines: list[str] = []
74+
# Wait for plugin to start
75+
start_deadline = time.monotonic() + _PLUGIN_START_TIMEOUT
76+
try:
77+
print(f"Monitoring {stdout_q!r} for plugin started message")
78+
while True:
79+
remaining_time = start_deadline - time.monotonic()
80+
print(f"Waiting {remaining_time} seconds for plugin to start")
81+
try:
82+
line = stdout_q.get(timeout=remaining_time)
83+
except Empty:
84+
assert False, "Plugin subprocess failed to start"
85+
print(line)
86+
startup_lines.append(line)
87+
if "Ctrl-C to terminate" in line:
88+
break
89+
finally:
90+
# Instruct the process to terminate
91+
print("Sending termination request to plugin subprocess")
92+
stop_deadline = time.monotonic() + _PLUGIN_STOP_TIMEOUT
93+
_interrupt_child_process(process, (stop_deadline - time.monotonic()))
94+
# Give threads a chance to halt their file reads
95+
# (process terminating will close the pipes)
96+
stdout_thread.join(timeout=(stop_deadline - time.monotonic()))
97+
stderr_thread.join(timeout=(stop_deadline - time.monotonic()))
98+
with process:
99+
# Closes open pipes
100+
pass
101+
# Collect remainder of subprocess output
102+
shutdown_lines = [*_drain_queue(stdout_q)]
103+
stderr_lines = [*_drain_queue(stderr_q)]
104+
return startup_lines, shutdown_lines, stderr_lines
105+
106+
107+
def _plugin_case_id(plugin_path: Path) -> str:
108+
return plugin_path.name
109+
110+
111+
@pytest.mark.lmstudio
112+
@pytest.mark.parametrize("plugin_path", _get_plugin_paths(), ids=_plugin_case_id)
113+
def test_plugin_execution(plugin_path: Path) -> None:
114+
startup_lines, shutdown_lines, stderr_lines = _exec_and_interrupt(plugin_path)
115+
# Stderr should start with the API stability warning...
116+
warning_lines = [
117+
*_PLUGIN_API_STABILITY_WARNING.splitlines(keepends=True),
118+
"\n",
119+
"warnings.warn(_PLUGIN_API_STABILITY_WARNING, FutureWarning)\n",
120+
]
121+
for warning_line in warning_lines:
122+
stderr_line = stderr_lines.pop(0)
123+
assert stderr_line.endswith(warning_line)
124+
# ... and then consist solely of logged information messages
125+
for log_line in stderr_lines:
126+
assert log_line.startswith("INFO:")
127+
# Startup should finish with the notification of how to terminate the dev plugin
128+
assert startup_lines[-1].endswith("Ctrl-C to terminate...\n")
129+
# Shutdown should finish with a graceful shutdown notice from the plugin runner
130+
assert shutdown_lines[-1] == "Plugin execution terminated by console interrupt\n"

0 commit comments

Comments
 (0)