From 3a8d6aaabbcb88d26ca18d82ba8742caa12f855f Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 19 Oct 2026 23:06:48 -0400 Subject: [PATCH] fix: Steam no longer sleeps again right after a paused session resumes (#1162) --- .../launcher/session/SessionService.kt | 23 ++-- .../overlay/usr/local/bin/droiddeck-agent | 60 +++++++++ .../overlay/usr/local/bin/droiddeck-login1 | 119 ++++++++++++++++-- tools/tests/test_droiddeck_agent.py | 27 ++++ tools/tests/test_login1.py | 51 +++++++- 5 files changed, 261 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index e9f7fb390..ff1a7aeeb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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() { diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 81d2ad5c0..255d3f255 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -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 [timeout-seconds]", file=sys.stderr) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 index c3b884e41..5c04842c0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 @@ -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 = """ @@ -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(): diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 986931a84..8997bff97 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -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"}}}}) diff --git a/tools/tests/test_login1.py b/tools/tests/test_login1.py index 9150d2d78..e257ccf81 100644 --- a/tools/tests/test_login1.py +++ b/tools/tests/test_login1.py @@ -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"]