Skip to content

Fix power off (standby) reliability by detecting playactor timeout-kills - #676

Merged
andrew-codes merged 1 commit into
FunkeyFlo:mainfrom
andrew-codes:fm/ps5-mqtt-power-off-fix-v2
Aug 17, 2026
Merged

andrew-codes merged 1 commit into
FunkeyFlo:mainfrom
andrew-codes:fm/ps5-mqtt-power-off-fix-v2

Conversation

@andrew-codes

Copy link
Copy Markdown
Collaborator

Fixes #675

The bug

PlayactorClient.runPowerCommand (shared by wake() and standby()) invokes the playactor CLI via sh.exec(command, { silent: true, timeout: 5000 }) and only checks if (stderr) throw stderr to detect failure.

Standby requires a full Remote Play handshake (discovery, session init/ctrl, login, passcode, then the actual standby command), which can exceed the shared 5s timeout that's plenty for a fire-and-forget wake. When it does, shelljs kills the process before standby completes. The kill produces no stderr, so the if (stderr) throw stderr guard never fires, and the saga optimistically reports STANDBY to Home Assistant even though the console never actually went to standby.

Credit to the original issue reporter's diagnostic writeup for identifying this shape of the bug and proposing the fix directions below.

What I verified vs. took on faith

  • Independently verified (not just trusted from the issue): shelljs' actual timeout-kill behavior, since I don't have PS5 hardware to reproduce this end-to-end. Ran sh.exec("sleep N", { silent: true, timeout: shortMs }) locally and confirmed it consistently returns { code: 1, stdout: "", stderr: "" } - no thrown error, no stderr. This confirms the load-bearing claim the whole fix depends on: a timeout-kill is otherwise indistinguishable from success under the old check.
  • Independently verified: check() in the same file already uses code > 1 && stderr rather than trusting stderr alone - but that exact check doesn't transfer to wake/standby. check's exit codes 0/1 are two legitimate successful outcomes (Awake/Standby) per playactor's own documented convention (see ExitCode.DeviceStandby = 1 in playactor's CLI source), whereas wake/standby have no such convention - they exit 0 on success and non-zero on any failure. So the correct check here is code !== 0, not code > 1.
  • Independently verified: the pinned playactor@0.4.1's CLI genuinely supports a --ps5 flag (DeviceOptions.deviceOnlyPS5 in its own options.js), confirmed by reading the installed package's source rather than assuming the flag exists.
  • Not verifiable without hardware: whether the new, longer timeouts are exactly right for every network/console combination. I picked values with margin above the documented handshake steps; see reasoning below.

The fix

In server/src/playactor/client.ts:

  1. Raised standby's playactor --timeout/--connect-timeout from 5000ms to 10000ms each, and its shelljs process timeout from 5000ms to 25000ms - comfortably covering discovery + connect + margin for login/passcode/the standby request itself, which have no timeout flag of their own. wake's timeouts are unchanged (5000ms) since it has no equivalent handshake latency - it's a fire-and-forget UDP wake packet, per the issue's own evidence for why wake "just works" while standby doesn't.
  2. runPowerCommand now falls back to checking the exit code when stderr is empty: if (code !== 0) throw .... This catches the timeout-kill case (code 1, no stderr) that the old stderr-only check missed, without changing behavior for the existing stderr-present failure path (still thrown as-is, preserving existing callers/tests that assert on the raw stderr value).
  3. Added the --ps5 flag to wake/standby commands when allowPs4Devices is false (mirrors the existing include_ps4_devices → allowPs4Devices config flow already used by discover-devices.ts), trimming discovery latency within the new timeout budget. Threaded allowPs4Devices through PlayactorClientSettings and app.ts's createPlayactorClient(...) call to make this available.

I scoped this fix to standby's timeouts only (not wake's) since the reported symptom and the handshake-latency root cause are specific to standby. The exit-code fallback check applies to both wake and standby since they share runPowerCommand and both have the identical latent gap - wake just doesn't usually hit it in practice, per the issue.

Tests

Added to server/src/playactor/__tests__/client.test.ts:

  • A regression test for wake and for standby: a timeout-kill-shaped result ({ code: 1, stdout: "", stderr: "" }) is rejected, rather than silently resolving.
  • Tests for the new --ps5 flag on both commands.
  • Updated the existing standby "builds the correct command" test for the new timeout values.

yarn test/unit, yarn typecheck, yarn lint, and yarn format/check all pass locally, with 100% statement/branch coverage on the touched file.

Standby requires a full Remote Play handshake (discovery, session init,
login, passcode, then the standby request itself) that can exceed the
previous 5s shelljs timeout shared with wake. When shelljs kills the
process on timeout, it exits with code 1 and empty stdout/stderr, so the
`if (stderr) throw stderr` guard in PlayactorClient.runPowerCommand never
fired and the saga optimistically reported STANDBY even though the
console never actually went to standby.

Verified shelljs' timeout-kill behavior locally (sh.exec("sleep N",
{ timeout: shortMs }) consistently yields code: 1, stdout: "",
stderr: "") since this couldn't be reproduced against real PS5 hardware.

- Raise standby's playactor discovery/connect timeouts to 10s each and its
  shelljs timeout to 25s, comfortably covering the full handshake. wake's
  timeouts are unchanged since it has no equivalent handshake latency.
- Fall back to checking the process exit code when stderr is empty, since
  playactor exits 0 on success and non-zero on any other failure - this
  catches the timeout-kill case that stderr alone misses, following the
  same code-based-check philosophy check() already uses.
- Pass playactor's --ps5 flag for wake/standby when PS4 devices are
  excluded, to reduce discovery latency within the timeout budget.
- Add regression tests in client.test.ts covering the timeout-kill path
  for both wake and standby, plus the new --ps5 flag behavior.

Fixes FunkeyFlo#675
@github-actions github-actions Bot added the patch label Aug 16, 2026
@andrew-codes
andrew-codes merged commit 45b7102 into FunkeyFlo:main Aug 17, 2026
11 checks passed
@andrew-codes
andrew-codes deleted the fm/ps5-mqtt-power-off-fix-v2 branch August 17, 2026 00:00
@dmcnaugh15

dmcnaugh15 commented Aug 17, 2026 •

Copy link
Copy Markdown

FYI: I dumped over 30 hours into this in the last week. I'm not a programmer, and Claude helped me every step of the way, but I did end up with a very reliable working setup using Playactor independently. Here are the relevant findings summarized by Claude:

I'm running playactor directly rather than through the add-on, against a PS5 on system software 13600007, so this is a slightly different vantage point — but it bears on the "not verifiable without hardware" question from the PR.

25s is the right ballpark but isn't always sufficient. I wrapped my standby call in timeout 25 and still see it killed at 25s under some conditions, not rarely. A clean standby against an awake, logged-in console completes in ~600ms. When it fails it doesn't fail slowly — it hangs indefinitely at the /sess/ctrl request with 0% CPU, blocked on a socket read that never returns. I've let one run 87 seconds without it resolving.

Failed attempts appear to leave stale Remote Play sessions on the console, and those poison subsequent attempts. After a killed attempt I see 403: Forbidden: Remote is already in use, 403: Forbidden: Other Error, or another silent stall at /sess/ctrl. Notably /sess/init still returns 200 with a nonce in these states, so the console isn't refusing outright — it's specifically the control-channel request that goes unanswered. The condition clears on its own after a few minutes, or immediately on console restart.

Practically this means a raised timeout alone may not be enough. What made it reliable for me was retrying with a gap: 25s cap per attempt, up to 4 attempts, ~30s between them. Two consecutive failures followed by success on the third, with nothing else changing, is a pattern I've seen repeatedly. Retrying without a gap seemed to make things worse — presumably stacking up more half-open sessions.

One other state worth knowing about: standby reliably hangs at /sess/ctrl when the console is awake but sitting at the "press the PS button" screen with no user signed in. A network WAKEUP gets you to that screen but doesn't establish a user session, and Remote Play standby appears to need one. Waking via the PS Remote Play app signs in fully, and standby then works normally. So "console is awake" isn't a sufficient precondition — it needs to be awake and logged in. This may explain some intermittent reports, since a console woken by the add-on itself lands in exactly that state.

Happy to test 1.7.0 against real hardware and report back if that's useful.

@vassilmladenov

Copy link
Copy Markdown

@andrew-codes this change causes standby requests to fail consistently on my system. Rolling back the image to 1.6.0 fixes it. On 1.7.0, I get a notification that a device connected to remote play, and then another disconnected notification a few seconds later; system remains on. Happy to send over logs if you can guide me where to look

@andrew-codes

Copy link
Copy Markdown
Collaborator Author

Thanks for the report. I'll look into this early this week. Logs would be helpful. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Power off doesn't reliably work

3 participants