Do not wait for a state that is already there

Apple TV · No. 138

Shipped

appletv 0.1.1 is a fix from the first live test of the read-back loop. Turning on a TV that was already on waited its full six-second ceiling before answering; launching the app that already owned now-playing waited its whole window too. Both now return in under a second, and the compact result table stopped saying “already on already”. This is a short post about a small trap: polling until something changes, when nothing can.

The loop that cannot end early

The controller from the previous release sends a command, then polls the field that command is supposed to change every half second, stopping as soon as the field moves or a per-command ceiling passes. That is the right shape when the command has work to do. It has a hole when the command is idempotent in the HTTP sense, where RFC 9110 defines an idempotent method as one whose intended effect from multiple identical requests is the same as for a single request. turn_on on a TV that reads on is exactly that. Nothing will change, so “poll until it changes” runs to the ceiling every time.

The cost is real. Six seconds per power command, three for a launch, and a compound command like “open Netflix” pays both. The Amazon Builders’ Library makes the general point about timeouts: setting one too high reduces its usefulness, because resources are still consumed while the client waits. Here the resource is the person watching a spinner for a TV that was on the whole time.

The fix: know what the command expects, and check before you wait

Start from the polling loop as it stood. read_state, dispatch and OBSERVES are the ones from the previous post; only press changes.

# atv.py (excerpt, previous release)
import asyncio, time

OBSERVES = {"turn_on": "power", "turn_off": "power", "pause": "playback", "launch_app": "app"}
CEILING = {"turn_on": 6.0, "turn_off": 6.0, "launch_app": 3.0}


async def press(atv, command, arg=None):
    before = await read_state(atv)
    await dispatch(atv, command, arg)
    what = OBSERVES.get(command)
    ceiling = CEILING.get(command, 4.0)
    reads, started = [], time.monotonic()
    while True:
        await asyncio.sleep(0.5)
        now = await read_state(atv)
        reads.append(now)
        if getattr(now, what) != getattr(before, what) or time.monotonic() - started >= ceiling:
            break
    return {"command": command, "arg": arg, "before": before, "reads": reads, "after": reads[-1]}

The fix is a second table next to OBSERVES: the value each command is expected to produce. When the state already reads that way before the send, the ceiling becomes zero and the loop reads once.

# atv.py (the fix)
EXPECTS = {
    "turn_on": ("power", "on"),
    "turn_off": ("power", "off"),
    "play": ("playback", "playing"),
    "pause": ("playback", "paused"),
}


def ceiling_for(command, arg, before):
    """Zero when the command's target state is already true; the field
    cannot move, so there is nothing to wait for."""
    exp = EXPECTS.get(command)
    if exp and getattr(before, exp[0]) == exp[1]:
        return 0.0
    if command == "launch_app" and before.app == arg:
        return 0.0
    return CEILING.get(command, 4.0)


async def press(atv, command, arg=None):
    before = await read_state(atv)
    await dispatch(atv, command, arg)
    what = OBSERVES.get(command)
    ceiling = ceiling_for(command, arg, before)
    reads, started = [], time.monotonic()
    while True:
        await asyncio.sleep(0.3 if not reads else 0.5)
        now = await read_state(atv)
        reads.append(now)
        if getattr(now, what) != getattr(before, what) or time.monotonic() - started >= ceiling:
            break
    return {"command": command, "arg": arg, "before": before, "reads": reads, "after": reads[-1]}

ceiling_for is a pure function of the command and the state before it, which is what makes it testable without a device. pyatv itself offers a related knob, await_new_state, which makes turn_on and turn_off wait for a state change before returning; I keep it off and own the wait in press, so the same ceiling logic covers playback and launches as well as power.

What the verdict says about a state that was already there

Skipping the wait raises the honest question: was the command a success? The read-back agrees with it, after all. The answer from the previous release stands. A state that already matched before the send proves nothing about the command, so the verdict is unverifiable with the reason “already on before the send, nothing to prove”, and the user-facing table shows already on. The controller in Åström and Murray’s framing compares the sensed state against the desired one and computes a corrective action; when there is no error to correct there is no action to verify, and reporting one would be inventing evidence.

Verify it without a TV

ceiling_for needs only a state object, so the check is a few lines:

# test_ceiling.py
from dataclasses import dataclass

CEILING = {"turn_on": 6.0, "turn_off": 6.0, "launch_app": 3.0}
EXPECTS = {"turn_on": ("power", "on"), "turn_off": ("power", "off"),
           "play": ("playback", "playing"), "pause": ("playback", "paused")}


@dataclass
class State:
    power: str = "on"
    playback: str = "paused"
    app: str = "com.netflix.Netflix"


def ceiling_for(command, arg, before):
    exp = EXPECTS.get(command)
    if exp and getattr(before, exp[0]) == exp[1]:
        return 0.0
    if command == "launch_app" and before.app == arg:
        return 0.0
    return CEILING.get(command, 4.0)


assert ceiling_for("turn_on", None, State(power="on")) == 0.0
assert ceiling_for("turn_on", None, State(power="off")) == 6.0
assert ceiling_for("pause", None, State(playback="paused")) == 0.0
assert ceiling_for("launch_app", "com.netflix.Netflix", State()) == 0.0
assert ceiling_for("launch_app", "com.apple.TVSettings", State()) == 3.0
print("ceiling_for: already-true states wait 0 s")
python3 test_ceiling.py
ceiling_for: already-true states wait 0 s

Against the real TV the difference was the whole point of the release: an already-on turn_on went from six seconds to 0.7, and “open Netflix” from 15.6 seconds to 4.9.

Gotchas

The bug only shows up on the second run. The first turn_on of a session hits a TV that is off, changes the field, and exits the loop in a second. Every later one hits a TV that is on and waits the full ceiling. My first timing pass measured cold commands and looked fine; the slow path only appeared when I chained turn_on in front of a launch as a safety step. The escape is to time the idempotent case on purpose: run every command twice and measure the second.

A launch has the same hole with a different field. launch_app is verified by the now-playing owner changing to the target. If that app already owns now-playing, the field cannot move, and the loop waits its ceiling for a change that is impossible. The before.app == arg branch in ceiling_for is the same fix applied to a different expectation, and it is why the table is keyed by command rather than by field.

Fixing the wait exposed the wording. With the wait gone, the “already” case became common enough to read, and the result column said “already on already”: a string replacement that dropped the tail of the reason and appended a word that was already there. Trivial, but it was the first thing the person reading the table saw, so it shipped in the same fix.

Sources

Changelog

  • appletv 0.1.1 — no read-back wait when the state already matches (#236) (c2e0f52)