fix: Steam no longer sleeps again right after a paused session resumes (#1162)

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-19 23:06:48 -04:00
1 parent 64b966af49
commit 3a8d6aaabb
5 files changed
+261 -19

No files matched your search

@@ -80,6 +80,7 @@ class SessionService : Service() {
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepIdle = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
@@ -1019,7 +1020,7 @@ class SessionService : Service() {
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
@@ -1219,16 +1220,22 @@ class SessionService : Service() {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
if (!steamPause) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1296,7 +1303,7 @@ class SessionService : Service() {
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
@@ -1307,13 +1314,14 @@ class SessionService : Service() {
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
@@ -1339,6 +1347,7 @@ class SessionService : Service() {
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = false
}
private fun requestSteamWake() {
@@ -532,6 +532,46 @@ def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15)
ws.close()
def steam_idle_inhibit(agent_dir, inhibit):
expression = """(async want => {
const toggle = () => new Promise(done => {
let handle = null;
let timer = null;
const finish = value => {
clearTimeout(timer);
handle?.unregister?.();
done(value);
};
timer = setTimeout(() => finish(null), 2500);
handle = SteamClient.Console.RegisterForSpewOutput(entry => {
const found = /Suspend inhibit: ([01])/.exec(entry?.spew || "");
if (found) finish(found[1] === "1");
});
SteamClient.Console.ExecCommand("toggle_idle_suspend_inhibit 1");
});
let state = await toggle();
if (state === !want) state = await toggle();
return state === want;
})(%s)""" % ("true" if inhibit else "false")
return cdp_eval(agent_dir, expression, timeout=12).get("value") is True
def steam_idle_suspend_seconds(agent_dir, vpower=Path("/run/vpower/ac_status")):
expression = """(() => {
const settings = globalThis.settingsStore?.clientSettings;
return settings ? [settings.system_idle_suspend_ac_sec, settings.system_idle_suspend_battery_sec] : null;
})()"""
seconds = cdp_eval(agent_dir, expression, await_promise=False, timeout=5).get("value")
if not isinstance(seconds, list) or len(seconds) != 2:
return None
try:
on_ac = vpower.read_text().strip() != "Disconnected"
except OSError:
on_ac = True
value = seconds[0 if on_ac else 1]
return max(0, int(value)) if isinstance(value, (int, float)) else None
def steam_selection(agent_dir, timeout=5):
result = cdp_eval(
agent_dir,
@@ -923,6 +963,26 @@ def main(argv):
state = "unknown"
print(state)
return {"yes": 0, "no": 5}.get(state, 1)
if argv and argv[0] == "--steam-idle-inhibit":
if len(argv) != 2 or argv[1] not in ("on", "off"):
print("usage: droiddeck-agent --steam-idle-inhibit on|off", file=sys.stderr)
return 2
try:
done = steam_idle_inhibit(Path(base) / "agent", argv[1] == "on")
except (AgentError, OSError, ValueError) as error:
print("droiddeck-agent: could not reach the client's idle sleep: %s" % error, file=sys.stderr)
return 1
return 0 if done else 1
if argv and argv[0] == "--steam-idle-suspend-seconds":
try:
seconds = steam_idle_suspend_seconds(Path(base) / "agent")
except (AgentError, OSError, ValueError) as error:
print("droiddeck-agent: could not read the client's idle sleep: %s" % error, file=sys.stderr)
return 1
if seconds is None:
return 1
print(seconds)
return 0
if argv and argv[0] == "--steam-shortcut-state":
if len(argv) not in (2, 3):
print("usage: droiddeck-agent --steam-shortcut-state <cgameid> [timeout-seconds]", file=sys.stderr)
@@ -28,6 +28,8 @@ UPOWER_ROOT = "/org/freedesktop/UPower"
NATIVE_PREPARE_MS = 1500
AGENT = Path(__file__).resolve().with_name("droiddeck-agent")
ASLEEP_WAIT = 6
INHIBIT_WAIT = 12
RELEASE_RETRIES = 8
INTROSPECTION = """
<node>
<interface name="org.freedesktop.login1.Manager">
@@ -58,8 +60,14 @@ class LoginManager:
self.native_request = self.directory / "steam-native-sleep"
self.native_ready = self.directory / "steam-native-ready"
self.pending = None
self.pending_idle = False
self.waiting = set()
self.timeout = None
self.preparation = None
self.inhibited = False
self.inhibit_lock = threading.Lock()
self.release = None
self.generation = 0
info = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0]
self.registration = bus.register_object(ROOT, info, self.method, self.property, None)
upower_info = Gio.DBusNodeInfo.new_for_xml(UPOWER_INTROSPECTION).interfaces[0]
@@ -89,6 +97,7 @@ class LoginManager:
reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing")
return
self.pending = uuid.uuid4().hex
self.pending_idle = False
self.notify(True)
reply.return_value(None)
self.bus.flush(None, self.publish_after_flush, self.pending)
@@ -101,7 +110,7 @@ class LoginManager:
self.resume()
return
if self.pending == token:
self.publish(self.request)
self.hold(token, self.publish, self.request)
def publish(self, request):
self.check_state()
@@ -119,15 +128,21 @@ class LoginManager:
def prepare_native(self):
try:
token = self.native_request.read_text().strip()
words = self.native_request.read_text().split()
self.native_request.unlink(missing_ok=True)
except OSError:
return
if not re.fullmatch(r"[a-f0-9]{32}", token) or self.pending is not None:
token = words[0] if words else ""
if not re.fullmatch(r"[a-f0-9]{32}", token) or words[1:] not in ([], ["idle"]) or self.pending is not None:
return
if self.read_state() == token + ":awake":
return
self.pending = token
self.pending_idle = words[1:] == ["idle"]
self.waiting = {"hold"} if self.pending_idle else {"hold", "prepare"}
self.hold(token, self.prepared, token, "hold")
if self.pending_idle:
return
self.notify(True)
self.bus.flush(None, self.prepare_after_flush, token)
@@ -144,10 +159,73 @@ class LoginManager:
def finish_preparation(self, token):
self.preparation = None
if self.pending == token:
self.prepared(token, "prepare")
return False
def prepared(self, token, step):
if self.pending != token:
return False
self.waiting.discard(step)
if not self.waiting:
self.publish(self.native_ready)
return False
def hold(self, token, then, *args):
self.cancel_release()
def work():
self.set_inhibit(True)
GLib.idle_add(self.after_hold, token, then, args)
threading.Thread(target=work, daemon=True).start()
def after_hold(self, token, then, args):
if self.pending == token:
then(*args)
elif self.pending is None:
self.release_later()
return False
def set_inhibit(self, inhibit, generation=None):
with self.inhibit_lock:
if (generation is not None and generation != self.generation) or self.inhibited == inhibit:
return
if steam_idle_inhibit(inhibit):
self.inhibited = inhibit
print("== login1: Steam idle sleep " + ("held" if inhibit else "released"), flush=True)
else:
self.inhibited = None
def cancel_release(self):
self.generation += 1
if self.release is not None:
GLib.source_remove(self.release)
self.release = None
def release_later(self):
self.cancel_release()
if self.inhibited is False:
return
generation = self.generation
threading.Thread(target=lambda: GLib.idle_add(self.schedule_release, generation, steam_idle_suspend_seconds()),
daemon=True).start()
def schedule_release(self, generation, seconds, attempt=0):
if generation == self.generation and self.pending is None:
self.release = GLib.timeout_add_seconds(seconds or 0, self.release_now, generation, attempt)
return False
def release_now(self, generation, attempt):
self.release = None
def work():
self.set_inhibit(False, generation)
if self.inhibited is not False and attempt < RELEASE_RETRIES:
GLib.idle_add(self.schedule_release, generation, 1 << attempt, attempt + 1)
threading.Thread(target=work, daemon=True).start()
return False
def changed(self, monitor, file, other, event):
if file.get_basename() == self.native_request.name or (other and other.get_basename() == self.native_request.name):
self.prepare_native()
@@ -178,8 +256,10 @@ class LoginManager:
def woken(self):
if self.pending is not None:
idle = self.pending_idle
self.resume(force=True)
return
if not idle:
return
threading.Thread(target=lambda: GLib.idle_add(self.replay_unless_awake, steam_asleep()), daemon=True).start()
def replay_unless_awake(self, asleep):
@@ -195,14 +275,18 @@ class LoginManager:
self.notify(True)
self.bus.flush(None, self.finish_recovery, None)
return
idle = self.pending_idle
self.pending = None
self.pending_idle = False
if self.preparation is not None:
GLib.source_remove(self.preparation)
self.preparation = None
if self.timeout is not None:
GLib.source_remove(self.timeout)
self.timeout = None
self.notify(False)
if not idle:
self.notify(False)
self.release_later()
def finish_recovery(self, connection, result, _data):
try:
@@ -213,13 +297,26 @@ class LoginManager:
self.notify(False)
def steam_asleep():
def agent(*args, timeout=ASLEEP_WAIT):
try:
code = subprocess.run([str(AGENT), "--steam-asleep"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=ASLEEP_WAIT).returncode
done = subprocess.run([str(AGENT), *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
return {0: True, 5: False}.get(code)
return None, ""
return done.returncode, done.stdout
def steam_asleep():
return {0: True, 5: False}.get(agent("--steam-asleep")[0])
def steam_idle_inhibit(inhibit):
return agent("--steam-idle-inhibit", "on" if inhibit else "off", timeout=INHIBIT_WAIT)[0] == 0
def steam_idle_suspend_seconds():
code, output = agent("--steam-idle-suspend-seconds")
return int(output) if code == 0 and output.strip().isdigit() else None
def load_netmanager():
+27
View File
@@ -338,6 +338,33 @@ class AgentTest(unittest.TestCase):
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")):
self.assertEqual(1, agent.main(["--steam-asleep"]))
def test_idle_inhibit_command_reports_whether_steam_reached_the_state(self):
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}):
for value, code in ((True, 0), (False, 1), (None, 1)):
with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask:
self.assertEqual(code, agent.main(["--steam-idle-inhibit", "on"]))
self.assertIn("toggle_idle_suspend_inhibit 1", ask.call_args.args[1])
self.assertTrue(ask.call_args.args[1].rstrip().endswith("(true)"))
with patch.object(agent, "cdp_eval", return_value={"value": True}) as ask:
self.assertEqual(0, agent.main(["--steam-idle-inhibit", "off"]))
self.assertTrue(ask.call_args.args[1].rstrip().endswith("(false)"))
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")):
self.assertEqual(1, agent.main(["--steam-idle-inhibit", "on"]))
self.assertEqual(2, agent.main(["--steam-idle-inhibit", "maybe"]))
def test_idle_suspend_seconds_follow_the_power_source(self):
vpower = self.dir / "ac_status"
with patch.object(agent, "cdp_eval", return_value={"value": [3600, 900]}):
vpower.write_text("Connected\n")
self.assertEqual(3600, agent.steam_idle_suspend_seconds(self.dir, vpower))
vpower.write_text("Disconnected\n")
self.assertEqual(900, agent.steam_idle_suspend_seconds(self.dir, vpower))
with patch.object(agent, "cdp_eval", return_value={"value": None}):
self.assertIsNone(agent.steam_idle_suspend_seconds(self.dir, vpower))
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}), \
patch.object(agent, "steam_idle_suspend_seconds", return_value=None):
self.assertEqual(1, agent.main(["--steam-idle-suspend-seconds"]))
def test_cdp_exception(self):
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
+50 -1
View File
@@ -77,6 +77,7 @@ class SleepHandshakeTest(unittest.TestCase):
GLib.Variant("(ss)", (IFACE, "PreparingForSleep")))[0]
def suspend(self):
(self.dir / "steam-sleep").unlink(missing_ok=True)
self.call(IFACE, "Suspend", GLib.Variant("(b)", (True,)))
self.wait_for(lambda: (self.dir / "steam-sleep").exists())
self.wait_for(lambda: self.signals == [True])
@@ -239,6 +240,7 @@ class FakeAgentCase(unittest.TestCase):
suspend = SleepHandshakeTest.suspend
state = SleepHandshakeTest.state
request_wake = SleepHandshakeTest.request_wake
request_native = SleepHandshakeTest.request_native
def asked(self):
path = self.dir / "agent-calls"
@@ -264,7 +266,7 @@ class AwakeSteamTest(FakeAgentCase):
self.state(token, "paused")
self.request_wake()
self.wait_for(lambda: self.signals == [True, False])
self.assertEqual([], self.asked())
self.assertNotIn("--steam-asleep", self.asked())
@unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject")
@@ -276,6 +278,53 @@ class AsleepSteamTest(FakeAgentCase):
self.wait_for(lambda: self.signals == [True, False])
self.assertEqual(["--steam-asleep"], self.asked())
def test_steam_sleep_request_holds_idle_sleep_before_the_host_pauses(self):
token = self.suspend()
self.assertEqual(["--steam-idle-inhibit", "on"], self.asked())
self.state(token, "paused")
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
self.wait_for(lambda: self.asked()[-1:] == ["off"])
self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds",
"--steam-idle-inhibit", "off"], self.asked())
def test_idle_pause_holds_idle_sleep_without_a_sleep_wake_pair(self):
token = self.request_native("2" * 32 + " idle").split()[0]
self.wait_for(lambda: (self.dir / "steam-native-ready").exists())
self.assertEqual(token, (self.dir / "steam-native-ready").read_text().strip())
self.assertEqual(["--steam-idle-inhibit", "on"], self.asked())
self.state(token, "paused")
self.state(token, "awake")
self.wait_for(lambda: self.asked()[-1:] == ["off"])
self.assertEqual([], self.signals)
self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds",
"--steam-idle-inhibit", "off"], self.asked())
def test_a_failed_release_is_tried_again(self):
failed = self.dir / "release-failed"
(self.dir / "bin/droiddeck-agent").write_text(
"#!/bin/sh\necho \"$@\" >> \"%s\"\n"
"if [ \"$2\" = off ] && [ ! -e \"%s\" ]; then : > \"%s\"; exit 1; fi\nexit 0\n"
% (self.dir / "agent-calls", failed, failed))
token = self.suspend()
self.state(token, "paused")
self.state(token, "awake")
self.wait_for(lambda: self.asked().count("off") == 2)
time.sleep(2.5)
self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds",
"--steam-idle-inhibit", "off", "--steam-idle-inhibit", "off"], self.asked())
def test_a_hold_steam_did_not_confirm_is_still_released(self):
(self.dir / "bin/droiddeck-agent").write_text(
"#!/bin/sh\necho \"$@\" >> \"%s\"\n[ \"$2\" = on ] && exit 1\nexit 0\n" % (self.dir / "agent-calls"))
token = self.suspend()
self.state(token, "paused")
self.state(token, "awake")
self.wait_for(lambda: self.signals == [True, False])
self.wait_for(lambda: self.asked()[-1:] == ["off"])
self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds",
"--steam-idle-inhibit", "off"], self.asked())
class WithNetworkManagerTest(SleepHandshakeTest):
ARGS = ["--with-netmanager"]