mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
serve: opt-in prevent_sleep keeps Windows awake while a request is running or queued (#1727)
This commit is contained in:
1 parent
392841e586
commit
a084fd2d72
4 files changed
+216
No files matched your search
@@ -718,6 +718,10 @@ print(r.choices[0].message.content)
|
||||
`high`) in `strata-<model>.json`, outside `"args"` (#1641): the engine has no such option, and `--reasoning-effort` in
|
||||
`"args"` is moved to this key with a note at startup. The web app's shared Chat settings ("Use for other apps too") and a
|
||||
request's own level win over it.
|
||||
- **Keep Windows awake while it works (opt-in, #1727).** `"prevent_sleep": true` in `strata-<model>.json` keeps the PC
|
||||
from going to sleep while at least one request is running or waiting (Windows' `SetThreadExecutionState` with
|
||||
`ES_SYSTEM_REQUIRED`; the screen may still turn off), and gives the normal sleep rules back when the server is idle
|
||||
and when it exits. Default off. On Linux and macOS it does nothing (one line at startup says so).
|
||||
- **A hard thinking budget (opt-in).** `"reasoning_budget_tokens": N` in a request (OpenAI or Anthropic) caps the
|
||||
thinking at N tokens: when it gets there the server ends it with a short wrap-up line and `</think>`, and the model
|
||||
answers from there (the engine continues from what it already holds, so nothing is read again). The wrap-up is
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Opt-in "prevent_sleep" (#1727): while at least one request is running or queued, the PC is kept awake.
|
||||
|
||||
Windows only. SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) keeps the system from idling into sleep
|
||||
(not the display: no ES_DISPLAY_REQUIRED); ES_CONTINUOUS alone puts the normal behaviour back. The state belongs
|
||||
to the thread that set it, so one daemon thread sets and clears it; it is cleared when idle and on exit.
|
||||
Elsewhere this does nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import sys
|
||||
import threading
|
||||
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
|
||||
|
||||
def _windows_call():
|
||||
import ctypes
|
||||
fn = ctypes.windll.kernel32.SetThreadExecutionState
|
||||
fn.argtypes = [ctypes.c_uint32]
|
||||
fn.restype = ctypes.c_uint32
|
||||
return fn
|
||||
|
||||
|
||||
class KeepAwake:
|
||||
def __init__(self, call=None):
|
||||
self._call = call
|
||||
self._count = 0
|
||||
self._cond = threading.Condition()
|
||||
self._closed = False
|
||||
self._thread = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, enabled: bool, say=print):
|
||||
"""None when off. On a system without SetThreadExecutionState: one line, and None."""
|
||||
if not enabled:
|
||||
return None
|
||||
if not sys.platform.startswith("win"):
|
||||
say('[strata] "prevent_sleep" is for Windows only: ignored here', flush=True)
|
||||
return None
|
||||
try:
|
||||
ka = cls(_windows_call())
|
||||
except (ImportError, AttributeError, OSError) as e:
|
||||
say(f'[strata] "prevent_sleep" could not be set up: {e}', flush=True)
|
||||
return None
|
||||
atexit.register(ka.close)
|
||||
say('[strata] prevent_sleep: Windows stays awake while a request is running or queued', flush=True)
|
||||
return ka
|
||||
|
||||
def _loop(self):
|
||||
held = False
|
||||
while True:
|
||||
with self._cond:
|
||||
while not self._closed and (self._count > 0) == held:
|
||||
self._cond.wait()
|
||||
if self._closed:
|
||||
break
|
||||
want = self._count > 0
|
||||
try:
|
||||
self._call(ES_CONTINUOUS | ES_SYSTEM_REQUIRED if want else ES_CONTINUOUS)
|
||||
held = want
|
||||
except Exception: # noqa: BLE001 never fail a request over this
|
||||
held = want
|
||||
try:
|
||||
self._call(ES_CONTINUOUS)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _start(self):
|
||||
if self._thread is None:
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True, name="keep-awake")
|
||||
self._thread.start()
|
||||
|
||||
def acquire(self):
|
||||
with self._cond:
|
||||
if self._closed:
|
||||
return
|
||||
self._count += 1
|
||||
self._start()
|
||||
self._cond.notify_all()
|
||||
|
||||
def release(self):
|
||||
with self._cond:
|
||||
self._count = max(0, self._count - 1)
|
||||
self._cond.notify_all()
|
||||
|
||||
def close(self):
|
||||
with self._cond:
|
||||
self._closed = True
|
||||
self._cond.notify_all()
|
||||
t = self._thread
|
||||
if t is not None and t is not threading.current_thread():
|
||||
t.join(timeout=2)
|
||||
@@ -2708,6 +2708,7 @@ class Service:
|
||||
self.shared = {} # the web app's Chat settings for every client (POST /settings)
|
||||
self.shared_path = None # where they are kept between starts (next to the config)
|
||||
self.config_effort = None # #1641: the config's "reasoning_effort": the level for requests with none
|
||||
self.keep_awake = None # #1727 (opt-in): serve.keepawake.KeepAwake, the config's "prevent_sleep"
|
||||
self.fifo = threading.Lock()
|
||||
self.slot_save_path = None # --slot-save-path: /slots/0?action=save|restore (off when None)
|
||||
self.embeddings = threading.local() # the current request's image embeddings file (GENI)
|
||||
@@ -3563,6 +3564,17 @@ class Service:
|
||||
return now
|
||||
|
||||
def run(self, ids, thinking, tools, max_new, sampling, cancel, force=None) -> Iterator[tuple[str, object]]:
|
||||
"""The request's events (see _run); with "prevent_sleep" in the config, the PC stays awake while it runs or waits."""
|
||||
ka = self.keep_awake
|
||||
if ka is not None:
|
||||
ka.acquire()
|
||||
try:
|
||||
yield from self._run(ids, thinking, tools, max_new, sampling, cancel, force)
|
||||
finally:
|
||||
if ka is not None:
|
||||
ka.release()
|
||||
|
||||
def _run(self, ids, thinking, tools, max_new, sampling, cancel, force=None) -> Iterator[tuple[str, object]]:
|
||||
"""Yields ("event", Event) as text arrives, then ("done", {"finish": .., "completion_tokens": ..}).
|
||||
`force` (forced_call): the opening of the call the reply must make - see prepare()."""
|
||||
defaults = {**self.sampling_defaults, **self.shared} # the config's, then the Chat settings shared with apps
|
||||
@@ -6093,6 +6105,10 @@ def main() -> int:
|
||||
sampling_defaults=sampling_defaults,
|
||||
fit_max_tokens=a.fit_max_tokens or cfg.get("fit_max_tokens") is True)
|
||||
svc.reasoning_close_retry = cfg.get("reasoning_close_retry") is True # #1053: opt-in, off by default
|
||||
if cfg.get("prevent_sleep") is not None and not isinstance(cfg["prevent_sleep"], bool):
|
||||
raise SystemExit(f"[strata] config \"prevent_sleep\" must be true or false, not {cfg['prevent_sleep']!r}")
|
||||
from serve.keepawake import KeepAwake
|
||||
svc.keep_awake = KeepAwake.create(cfg.get("prevent_sleep") is True) # #1727: opt-in, Windows only
|
||||
if cfg.get("reasoning_effort") is not None: # #1641: the thinking level of requests that name none
|
||||
try:
|
||||
svc.config_effort = clean_shared_defaults({"reasoning_effort": cfg["reasoning_effort"]}).get("reasoning_effort")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""#1727: prevent_sleep. The Windows call is mocked; the real call is tested on Windows by hand.
|
||||
|
||||
python -m unittest serve.test_keepawake -v
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from serve.frontend import ChatTemplate
|
||||
from serve.keepawake import ES_CONTINUOUS, ES_SYSTEM_REQUIRED, KeepAwake
|
||||
from serve.server import ByteTokenizer, MockEngine, Service
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ON = ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
||||
|
||||
|
||||
def wait_for(cond, t=3.0):
|
||||
end = time.time() + t
|
||||
while time.time() < end:
|
||||
if cond():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return cond()
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self):
|
||||
self.calls, self.threads = [], set()
|
||||
|
||||
def __call__(self, flags):
|
||||
self.calls.append(flags)
|
||||
self.threads.add(threading.get_ident())
|
||||
return 0
|
||||
|
||||
|
||||
class KeepAwakeTests(unittest.TestCase):
|
||||
def test_held_while_busy_and_released_when_idle(self):
|
||||
rec = Recorder()
|
||||
ka = KeepAwake(rec)
|
||||
self.assertEqual(rec.calls, []) # nothing until a request
|
||||
ka.acquire()
|
||||
ka.acquire() # two requests: still one hold
|
||||
self.assertTrue(wait_for(lambda: rec.calls == [ON]))
|
||||
ka.release()
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(rec.calls, [ON]) # one still running
|
||||
ka.release()
|
||||
self.assertTrue(wait_for(lambda: rec.calls == [ON, ES_CONTINUOUS]))
|
||||
ka.close()
|
||||
self.assertEqual(rec.calls[-1], ES_CONTINUOUS)
|
||||
self.assertEqual(len(rec.threads), 1) # set and cleared by the same thread
|
||||
self.assertFalse(ON & 0x2) # never ES_DISPLAY_REQUIRED
|
||||
|
||||
def test_exit_clears_a_held_state(self):
|
||||
rec = Recorder()
|
||||
ka = KeepAwake(rec)
|
||||
ka.acquire()
|
||||
self.assertTrue(wait_for(lambda: rec.calls == [ON]))
|
||||
ka.close()
|
||||
self.assertEqual(rec.calls, [ON, ES_CONTINUOUS])
|
||||
|
||||
def test_a_failing_call_never_breaks_a_request(self):
|
||||
def boom(flags):
|
||||
raise OSError("no")
|
||||
ka = KeepAwake(boom)
|
||||
ka.acquire()
|
||||
ka.release()
|
||||
ka.close()
|
||||
|
||||
def test_off_and_non_windows(self):
|
||||
say = mock.Mock()
|
||||
self.assertIsNone(KeepAwake.create(False, say))
|
||||
say.assert_not_called()
|
||||
with mock.patch("serve.keepawake.sys.platform", "linux"):
|
||||
self.assertIsNone(KeepAwake.create(True, say))
|
||||
self.assertIn("Windows only", say.call_args[0][0])
|
||||
|
||||
def test_service_holds_for_the_whole_request_including_early_close(self):
|
||||
tok = ByteTokenizer()
|
||||
svc = Service(MockEngine(tok, "ok"), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
|
||||
rec = Recorder()
|
||||
svc.keep_awake = ka = KeepAwake(rec)
|
||||
kinds = [k for k, _ in svc.run(tok.encode("hi"), False, None, 8, {}, threading.Event())]
|
||||
self.assertIn("done", kinds)
|
||||
self.assertTrue(wait_for(lambda: rec.calls == [ON, ES_CONTINUOUS]))
|
||||
gen = svc.run(tok.encode("hi"), False, None, 8, {}, threading.Event())
|
||||
next(gen)
|
||||
self.assertTrue(wait_for(lambda: rec.calls[-1] == ON))
|
||||
gen.close() # the client left
|
||||
self.assertTrue(wait_for(lambda: rec.calls[-1] == ES_CONTINUOUS))
|
||||
ka.close()
|
||||
|
||||
def test_default_service_has_none(self):
|
||||
tok = ByteTokenizer()
|
||||
svc = Service(MockEngine(tok, "ok"), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
|
||||
self.assertIsNone(svc.keep_awake)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user