You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Connected steady state with no MPRIS player running now uses zero application timers.
Metric
Before
After
CPU ticks/min (idle)
13
0.00
D-Bus calls/min (idle)
30
0
CPU ticks/min (playing)
13
7.5
Measured on a live daemon via /proc CPU deltas and dbus-monitor call rates — Go timers are runtime-managed, so there are no timerfds to count.
No changes to wire format, protocol, TLS, or pairing behavior, and no plugin contract changes. Everything new is config-gated with safe defaults, so existing configs upgrade untouched.
Wake-up reductions
MPRIS: local poller only runs while something is playing (2a047c1)
Removed the standing 2s ticker along with dead hash-comparison code. The position ticker now exists only while at least one local player reports IsPlaying. PosAnchorMs is stamped on every local broadcast and exposed on debug status. New config: [mpris] poll_while_playing = true, position_interval = "2s".
Remote poller gated on active watchers (04a49de)
Added Bus.OnSubscriberChange. The remote ticker itself only exists while someone subscribes to mpris.update — not merely suppressed per tick.
Reconnect is now sighting-driven instead of timer-driven (68c64b5)
Redial parks on a per-device wake channel. A sighting (UDP/mDNS) triggers an immediate dial against the freshest known address; if that fails, backoff escalates to fallback_max (1h) and gives up past stale_after (24h) until the next sighting. Also fixed a goroutine leak where unpairing didn't close the parked channel. New config: [reconnect] sighting_driven = true, fallback_max = "1h", stale_after = "24h".
mDNS browse now runs only while something owns it (f260062)
Periodic probes (4s → 60s, plus a 10s cleanup pass, source-verified) only run while pairing or reconnect needs them. Also fixed a leaked results goroutine and a double-close panic when an owned browse was cancelled.
New keepalive_idle knob (586b706)
Default stays 30s, floor is 10s, wired through the listener, outbound dial, and side-channel paths.
MPRIS reliability — found by live testing, not by CI
Deploying the idle work and actually playing music exposed a deadlock the unit tests could not: the position poller only armed from the cachedIsPlaying flag, but that cache is refreshed only by a signal or by the poller itself. Lose one PlaybackStatus edge and the poller stayed down for the rest of the session — the phone's now-playing froze while audio played.
Arm on any observed change, not on the cached flag, and let the poller decide its own lifetime from live D-Bus reads (108ba26).
Generation guard so a self-stopping poller cannot orphan its successor into an unstoppable second ticker.
A failed read is not a paused player. Firefox's MPRIS endpoint answers intermittently; treating an error as "playback ended" stranded the poller on the first hiccup. Completeness is now tracked separately, with a bounded retry backstop.
10s watchdog re-arms a stranded poller (f2023dd). The stale window is now bounded regardless of signal reliability — verified live: poller stopped 05:25:55 on pause, watchdog re-armed 05:26:33 with no kcd command in between.
Deliberate trade-off: while an MPRIS player is tracked but paused, the daemon performs one D-Bus read per 10s. "Zero timers at idle" therefore means zero when no MPRIS player is tracked, not zero on a desktop with a media player merely running. ~6 reads/min is a fair price for not freezing the display. Set poll_while_playing = false to remove both the poller and the watchdog.
Packaging / infra
kcd AUR source package (28f9c64), already live as 1.19.1-1. Added an aur_sources entry and generalized aur-push.sh so it picks up future tags automatically.
Committed flake.lock with a container-verified vendor hash (3183ab5).
install.sh derives its version from the newest release tag instead of git describe, which labelled a post-release branch with a pre-release tag (2e2dd81).
Review follow-ups: just clean no longer deletes tracked packaging/ sources; the pairing verification code is returned to the CLI; kcd run list returns a list instead of dropping the phone's reply; skipping a paused player no longer force-plays; one device-resolution rule across commands; fish completions brought back in sync (8c55cd1..baa21f5).
Verification
Every commit: gofmt, vet, full test suite, -race on touched packages, golangci-lint clean, static binary build, goreleaser check. Integration tests now run on pull requests too — they previously only ran on the post-merge main push, so a feature branch never exercised them. The daemon was deployed and driven live throughout: idle and playing CPU, phone reconnect, arm/disarm transitions, and the stranded-poller recovery were all confirmed by hand. AUR source package went through a full makepkg build and namcap clean.
vendorHash sha256-/rT2... verified with a real nix build inside a
disposable nixos/nix container (build + flake check both green).
Also commit the generated flake.lock, pinning nixpkgs to 20b1ddd and
flake-utils/systems. Root cause of the repeated rot: the floating
nixos-unstable toolchain changes go mod vendor output with zero movement
in go.mod/go.sum, so each new toolchain invalidates vendorHash. The lock
makes the toolchain (and the hash) stable until we deliberately re-lock.
Delete the standing 2s runPollingLoop: with zero or paused players the
daemon now costs zero D-Bus wakeups (measured 13->5 ticks/min, 30->0
GetAll/min with a paused player). A position ticker exists only while at
least one local player IsPlaying, armed/disarmed via storeLocalState and
removePlayer; scoped to playing players.
Stamp PosAnchorMs on every local broadcast (mirroring the remote path)
and expose it on DebugPlayerInfo from the cached anchor, so receivers
extrapolate live position without polling.
New [mpris] keys: poll_while_playing (default true; false = pure
event-driven) and position_interval (default 2s, validated >0).
Docs: example.toml, CLI.md config table, CLIENT_GUIDE local idle note.
pollRemoteStates now returns immediately with zero mpris.update
subscribers: refresh requests only go out while somebody listens, so an
unwatched playing phone costs zero packets. Subscribing re-arms the
refresh within one interval, preserving the documented freshness
guarantee for the watched case.
New Bus.HasSubscribers(type) scans live subscribers under RLock
(empty-filter subscribers match everything) — no counter state to keep
in sync on unsubscribe. Docs (CLIENT_GUIDE, IPC_PROTOCOL freshness
paragraphs) updated to while-watched language.
reconnectWithBackoff no longer spins a per-device timer for
usually-failing dials: a discovery sighting (peer provably alive) dials
immediately via a wake channel, untriggered waits escalate to
fallback_max (1h), and past stale_after (24h) of silence the loop exits
entirely — zero timers for pairs that will never return. A future
sighting respawns the loop from the discovery path with a fresh backoff.
Mechanism: Device.reconnectWake (buffered-1, nil-safe), poked from the
paired-sighting branch and on unpair transitions in SetState (no parked
goroutine leak). Sighting bursts coalesce via a 5s trigger gap.
sighting_driven=false restores the legacy pure-timer loop.
New [reconnect] keys: sighting_driven (default true), fallback_max
(default 1h, >= max_backoff), stale_after (default 24h). Existing
configs overlay on defaults, so upgrades are unaffected.
zeroconf Browse re-queries periodically (4s exponential backoff to 60s
cap) plus a 10s cache-cleanup ticker, so lifetime-on browsing is a
fourth standing wakeup source. Browse now shares the broadcast
controller lifetime: it runs while pairing or reconnect owners hold the
loop and stops at connected steady state, where the lifetime UDP
listener and (responder-only) mDNS advertisement cover inbound
discovery.
Wiring: BroadcasterController.SetBrowseStarter, launched on the same
owned context in StartOwned; daemon registers Listener.RunMdnsDiscovery
in runTransport. Also closes the entries channel when Browse returns,
fixing a leaked results goroutine on every browse stop.
[network] keepalive_idle (default 30s, floor 10s) tunes the TCP
keepalive first-probe delay on the inbound listener, outbound dials,
and side-channel dials, which previously shared a hardcoded 30s.
Behavior at default is byte-identical; larger values trade slower
zombie detection for fewer kernel probes on idle connections.
Also documents the release invariant in ARCHITECTURE.md: connected
steady state keeps zero application timers, with the gating table for
every former periodic source plus measured numbers (2 ticks/min, 0
D-Bus calls/min, down from 13 + 30). New periodic work must be
owner- or activity-gated, never standing.
Reconcile healed player names only: a missed or stale Play signal left
the position poller disarmed with no further signal arriving to correct
it. Reconcile now also refreshes tracked-player state (GetAll per
player on rare, event-driven triggers — unknown senders, explicit
queries, connects), sharing the extracted localStateChanged compare
with the playing-poller tick. Read failures change nothing; nil bus is
a safe no-op.
Also exposes PosAnchorMs on ipc.MprisPlayerInfo: the daemon stamped
every local broadcast, but the CLI decoded through a parallel struct
that dropped the field, so kcd mpris raw never showed it.
…nership)
CLI.md claimed TCP keepalive was a fixed implementation setting; it is
now tunable via network.keepalive_idle. AGENTS.md gains the missing
MPRIS constructor row (including the new MPRISConfig param).
ARCHITECTURE.md broadcast paragraph now describes the ownership model
(pairing + reconnect owners, zero-owner steady state) and links the
idle-behavior section.
Second AUR target alongside kcd-bin: stable source builds with makedepends
go, provides kcd, conflicts kcd-bin. Requires the aur_sources key (not
aursources) and source.enabled for the release source tarball.
aur-push.sh now loops both repos with the same options-patch and push
flow. Verified with a snapshot build plus a full makepkg run in a
disposable Arch container (namcap clean, package builds).
Release v1.20.0 reduces idle wake-ups through activity-gated MPRIS polling, reconnects, and mDNS browsing, while adding configurable keepalives and AUR source packaging.
Changes:
Gates polling and discovery on activity.
Adds keepalive, reconnect, and MPRIS configuration.
Updates packaging, tests, documentation, and Nix locking.
File
Reviewed change / final finding
scripts/aur-push.sh
Generalizes AUR publishing for binary and source packages.
packaging/kcd.example.toml
Documents new configuration settings.
internal/transport/sidechannel.go
Configures outbound side-channel keepalive.
internal/transport/listener.go
Applies keepalive to accepted connections.
internal/transport/keepalive.go
Adds configurable keepalive defaults.
internal/plugins/mpris/watcher.go
Removes the standing MPRIS poll loop.
internal/plugins/mpris/types.go
Adds anchor/debug fields. Moderate (1 vote):omitempty drops valid zero volume values from status responses.
internal/plugins/mpris/signals.go
Centralizes local state storage.
internal/plugins/mpris/remote.go
Gates remote refresh requests. Moderate (3 votes): the ticker still runs without subscribers, leaving an idle timer.
internal/plugins/mpris/remote_gate_test.go
Tests subscriber-gated refreshes.
internal/plugins/mpris/reconcile.go
Reconciles tracked player state. Moderate (3 votes): position changes are ignored, so stale anchors may not be broadcast.
internal/plugins/mpris/playing_poller.go
Adds playback-scoped polling. Moderate (1 vote): a player removed during a blocking read can be reinserted with an armed poller.
internal/plugins/mpris/playing_poller_test.go
Tests polling lifecycle behavior.
internal/plugins/mpris/mpris.go
Accepts MPRIS configuration.
internal/plugins/mpris/mpris_test.go
Updates constructor and polling tests.
internal/plugins/mpris/local.go
Stamps local position anchors. Critical (3 votes): anchor mutation can race with DebugStatus.
internal/ipc/proto.go
Exposes position anchors through IPC.
internal/events/bus.go
Adds subscriber detection.
internal/events/bus_test.go
Tests subscriber detection.
internal/discovery/listener.go
Separates UDP listening from mDNS browsing.
internal/discovery/listener_mdns.go
Adds owned mDNS browsing.
internal/discovery/discovery_test.go
Tests browse ownership.
internal/discovery/broadcaster_controller.go
Couples browsing to ownership. Moderate (1 vote): startup ordering can omit browsing until ownership is cycled.
internal/device/device_core.go
Adds reconnect wake signaling.
internal/daemon/transport.go
Integrates sightings, reconnects, and mDNS registration. Moderate (3 votes): fallback can use a stale roam address. Moderate (2 votes): startup ordering can omit the browse starter. Moderate (1 vote): a parked loop retains escalated backoff after a new sighting.
internal/daemon/transport_reconnect.go
Implements parked reconnect backoff. Moderate (1 vote): inbound connections do not wake the parked loop, risking delayed replacement reconnects.
internal/daemon/transport_reconnect_test.go
Tests reconnect behavior.
internal/daemon/transport_dial.go
Applies configured keepalive.
internal/daemon/plugins.go
Wires plugin and side-channel settings. Moderate (1 vote): accepted side-channel listeners still use a hard-coded keepalive.
internal/config/runtime.go
Defines and validates new settings. Moderate (2 votes): existing configs with larger max_backoff can fail validation unexpectedly.
internal/config/config.go
Adds defaults and MPRIS configuration.
internal/config/config_test.go
Tests configuration changes.
flake.nix
Updates the vendor hash.
flake.lock
Pins Nix dependencies.
docs/IPC_PROTOCOL.md
Documents subscriber-gated MPRIS refreshes.
docs/CLIENT_GUIDE.md
Documents anchors and polling.
docs/CLI.md
Documents configuration defaults.
docs/ARCHITECTURE.md
Documents zero-idle behavior.
AGENTS.md
Updates the MPRIS constructor reference.
.goreleaser.yaml
Adds AUR source-package generation and install notes.
Six review findings plus a crash found by the integration gate:
- mpris: stamp PosAnchorMs and snapshot the broadcast state under p.mu;
the stamp aliased the lastStates pointer that DebugStatus reads from
the IPC path, and the new race test reproduces both the stamp/read
race and concurrent-broadcast marshal race under -race.
- config: enforce reconnect.fallback_max >= max_backoff only when
sighting_driven is true. Legacy configs that raised max_backoff above
the new 1h default previously failed to load; fallback_max is unused
in legacy mode.
- reconnect: reload the dial target from LastSightedIP on every parked
loop lap, so a roam survives a failed one-shot dial instead of
falling back to the stale spawn-time address.
- discovery: SetBrowseStarter now attaches to an already-running owned
loop, closing the startup window where reconnect ownership began
before the starter was registered (mDNS-only networks stayed silent).
- discovery: zeroconf closes the entries channel itself on ctx cancel;
our defer close(entries) double-closed it and panicked whenever an
owned browse was cancelled (caught by integration -race).
- mpris: include position drift past 3s in the local change comparison.
Steady playback stays silent because the phone extrapolates from the
anchor; seeks, stalls, and missed signals now re-broadcast.
- events: add OnSubscriberChange hooks; the remote MPRIS poller ticker
now exists only while a subscriber listens for mpris.update instead
of waking every 5s with the request suppressed.
Gate: gofmt, vet, unit, race on touched packages, integration -race,
static build all green. golangci-lint cannot run on this machine (its
embedded go/types is built with go1.26 while the toolchain is go1.27;
pre-existing, fails identically on the untouched tree).
Addressed all six Copilot review findings in e64218b, plus one crash the integration gate caught.
#
Finding
Resolution
1
HIGH — PosAnchorMs race (local.go:76)
Stamp + snapshot under p.mu before marshal. The stamp aliased the lastStates pointer that DebugStatus reads from the IPC path; the new -race test reproduced both that race and a second one between concurrent broadcasts' marshal and stamp.
2
MED — fallback_max breaks old configs (runtime.go:99)
Relationship now enforced only when sighting_driven = true. fallback_max is unused in legacy mode, so a pre-1.20 max_backoff > 1h config keeps loading. Compat test added.
3
MED — fallback dials stale address (transport.go:194)
New Device.LastSightedIP(); the parked loop reloads its dial target every lap, so a roam survives a failed one-shot dial. Test fails on the old code (5s timeout, zero dials), passes now.
4
MED — browse-starter startup race (transport.go:239)
SetBrowseStarter now attaches to an already-running owned loop instead of waiting for the next ownership cycle. Test covers the StartOwned → SetBrowseStarter order.
5
MED — Pos excluded from change detection (reconcile.go:154)
Position now counts only on drift >3s off the anchor extrapolation. Steady playback stays silent (the phone extrapolates from posAnchorMs); seeks, stalls, and missed signals re-broadcast. Four tests: steady/seek/stall/paused.
6
MED — standing 5s ticker with no subscribers (remote.go:229)
events.Bus.OnSubscriberChange hook; the remote poller ticker now exists only while someone listens for mpris.update. pollRemoteStates keeps its guard for the subscribe/unsubscribe race.
Keepalive setting is not applied to inbound side-channel listeners
internal/daemon/plugins.go:40
Passing KeepAliveIdle into SidechannelOptions only configures outbound DialSidechannel calls. The server side of transfers still creates its listener with a hard-coded 30s keepalive in share.ListenSideChannel, and MPRIS album-art uses that path without these options, so changing [network].keepalive_idle does not apply to all side-channel connections as documented. Thread this value through the listener/album-art paths as well.
Duplicate outbound handshakes can race for the same sighting
internal/daemon/transport.go:196
This path can launch two outbound handshakes for the same sighting: the earlier new-IP branch already starts a direct DialDevice, then this block starts a reconnect loop that immediately dials the same LastSightedIP. The two sessions can race duplicate resolution and flap the connection. In sighting-driven mode, either let the reconnect loop own the discovery dial or only start it after the one-shot dial fails.
Removed players can be reinserted into state tracking
internal/plugins/mpris/playing_poller.go:19
This accepts and caches a state without verifying that the player is still present in p.players. An in-flight playerState call can finish after removePlayer deletes the player, reinsert a playing entry into lastStates, and re-arm the poller; the removed player is then never iterated again, leaving a timer running and a ghost cache entry. Recheck tracking under the lock before storing (and avoid re-arming for a removed player).
`rm -rf packaging/` destroyed tracked files the release depends on:
systemd units, shell completions, the example config, and the firewall
profiles. Clean only generated output now.
Only 1716/udp was listed, so a UFW user got working discovery and
indefinitely hanging connections. Point at the kcd profile we already
ship (1716/udp, 1716/tcp, 1739:1764/tcp) and say what each range is for.
RequestPairing already derived the out-of-band code and logged it, but
only the daemon log saw it. A user running `kcd pair <device-id>` got
"Pair request sent" and no code, so the check the code exists for — the
user confirming the phone is really the phone — was impossible to
perform from this side.
RequestPairing now returns the code, handlePair ships it as PairResult,
and the CLI prints it with the mismatch warning. Empty on the two paths
that have no code of ours to show: already paired, and accepting a
request the peer initiated (listen mode already printed that one).
Integration tests cover both directions: an outbound request yields an
8-hex-character key, an inbound accept yields none.
The command was dead on arrival. CmdRunList sent the request and
returned OK immediately, but the plugin only registered
kdeconnect.runcommand.request as an incoming type — so the phone's
kdeconnect.runcommand reply hit "unhandled packet type" and was
dropped. Nothing could ever consume the result, and the CLI's "run kcd
watch to see results" pointed at a runcommand.list event that does not
exist.
The plugin now claims the reply type and hands the decoded list to a
per-device waiter registered before the request goes out, so a fast
phone cannot answer into a void. CmdRunList blocks on that waiter with
the plugin's 10s deadline — the same shape as the sftp mount route —
and returns the list; the CLI prints name<TAB>command.
Delivery to a waiter is non-blocking, so a late or unsolicited list
cannot stall the device's read loop, and a second concurrent request for
the same device fails fast rather than racing for the single reply.
next/previous sent the skip and then unconditionally sent Play, so
skipping a paused player unpaused it and started audio the user never
asked for. The Play nudge exists for phones whose MPRIS implementation
stops after a track change, and that is only needed when something was
playing to begin with.
Both commands now read the player's state first and nudge only if it was
playing. State the daemon cannot report — query failure, unknown device,
no matching player — defaults to nudging, preserving the workaround for
the phones that need it instead of silently dropping it.
Three commands (clipboard, connectivity, findmyphone) each carried their
own copy of "first paired connected device, else error", while battery,
ping, lock, unlock, volume, and contacts all demanded an explicit ID.
The same setup therefore behaved differently depending on which command
you reached for.
The loop is now resolveDeviceID: an explicit argument wins, a lone
paired connected device is selected, and several candidates is an error
naming them rather than a silent pick against the wrong phone. Applied
to every command whose only positional is the device. Commands with
further positionals (volume set/mute) keep it mandatory so their
remaining arguments stay unambiguous.
mpris actions also take the device positionally now, matching the rest
of the CLI; --device remains as a fallback and the positional wins.
lock/unlock said "Lock the current desktop session" and the docs claimed
they shell out to loginctl. Neither was true: the CLI sends the KDE
Connect lock packet to the remote device. loginctl is the daemon's
behavior when a phone asks *this* desktop to lock.
The fish completion is the only hand-maintained one — bash and zsh
delegate to `kcd --generate-bash-completion` and pick up new commands
for free — so it had drifted: dismiss, contacts, and volume were absent
from __kcd_cmds and had no completions at all.
unpair was also grouped with the commands that complete only connected
devices, which is backwards: removing a device that is offline or broken
is the main reason to run it. It now completes every known device.
Also corrected the lock/unlock descriptions, which claimed to act on the
local session, and added positional device completion to the mpris
action subcommands.
`git describe --tags --always --dirty` reports the newest tag reachable
from HEAD, not the newest release. dev/next forked at fb81751, before
the v1.19.1 release merge on main, so describe walked past v1.19.1 and
labelled the build v1.18.2-42-gbaa21f5 — 1.19.x code reported as 1.18.2.
The base is now the newest v* tag in the repo:
v1.20.0 checkout exactly on a release tag
v1.19.1+42.gbaa21f5 N commits past that tag on this branch
v1.19.1-dev+gbaa21f5 branch that forked before the newest tag
dev no tags, or not a git checkout
The forked case deliberately reports no commit count: the tag is not an
ancestor, so any number would be fiction. -dirty is still appended for
an uncommitted tree; bin/ is gitignored so the build output itself does
not trigger it.
Live testing found the position poller never armed when playback
started. Root cause is a self-sustaining deadlock: syncPlayingPollerLocked
gated the arm on the cached IsPlaying flag, but that cache is refreshed
only by a signal or by the poller itself. Lose one PlaybackStatus
signal and the cache stays "paused", so the poller stays disarmed, so
nothing refreshes the cache — and no further PlaybackStatus signal
arrives until the next pause/play. The removed 2s timer used to break
this by refreshing unconditionally; deleting it removed the safety net.
- Arm on any observed change rather than on the cached flag. The poller
verifies against live D-Bus reads, so a stale cache costs one tick.
- Let the poller stop itself once a live read shows nothing playing, so
its lifetime follows the player instead of the cache that armed it.
- Guard the self-stop with a generation token; otherwise a poller that
stops on its own clears its successor's handle and a second ticker
runs unstoppable.
- Narrow the NameOwnerChanged match to arg0prefix org.mpris.MediaPlayer2.
It previously matched every name acquired on the session bus, each
handled with blocking D-Bus calls, and godbus drops signals when its
64-slot buffer overflows. A dropped signal is how the deadlock started.
- Log the initial player-listing error, previously discarded with `_`.
Measured after the fix: local playback arms the poller (~32 GetAll/min
at the 2s interval) and idle stays at 0.00 CPU ticks/min with zero
context switches.
…tch rule
Live testing of 66c352c found two defects, both caught on the running
daemon rather than in tests.
The arg0prefix match rule in 66c352c is not valid D-Bus syntax — match
rules have no string-prefix key — so AddMatchSignal failed and the
watcher crash-looped every 3s with "Invalid match rule". Reverted to the
unfiltered name watch; non-MPRIS signals are already rejected in the
handler on a prefix check before any blocking work, so the flood is not
worth a filter the bus cannot express. The larger buffer stays.
More seriously, the poller treated a failed live read as "nothing is
playing" and exited. Firefox's MPRIS endpoint answers intermittently, so
a single hiccup stopped sampling and, with no further signal while
playback continues, the poller never restarted — the same strand the
previous commit set out to remove, just reached by a different road.
pollPlayingPlayers now reports completeness separately: only a full set
of successful reads reporting no playback ends the ticker. Failed reads
retry, bounded by maxConsecutiveReadFailures so a name outliving a dead
object cannot pin a ticker on.
Verified live after restart, with no reconcile and no manual nudge:
Firefox discovered by the initial listing, poller armed on its own at
17 GetAll/30s (~34/min, the 2s interval), 2.22 CPU ticks/min while
playing, 0 voluntary context switches.
The integration job was gated on `github.event_name == 'push'`, but the
push trigger is scoped to main/master — so a feature branch only ever
fires pull_request, and integration never ran before merge. It would
first have executed on the post-merge main push, with the release branch
already carrying the change.
The poller arms on an observed change, stops on a confirmed pause, and
only restarts when another change re-arms it. That leaves it hostage to
signal delivery: lose the PlaybackStatus=Playing edge and the poller
stays down for the rest of the session, so the phone's now-playing
freezes while audio plays. Live testing showed exactly this — armed at
05:04:37, stopped on pause at 05:05:43, never armed again despite
playback resuming, and the drift broadcasts to the phone stopped with it.
Firefox's MPRIS endpoint answers intermittently, so a missed edge is
routine rather than a corner case, and chasing which signal path dropped
it is not a good use of a release. A watchdog now samples every 10s and
re-arms the poller when it finds unpolled playback, bounding the stale
window regardless of signal reliability. It runs only while a local
player is tracked, so a desktop with no MPRIS app keeps zero timers.
Verified live: poller stopped 05:25:55 on pause, watchdog re-armed
05:26:33, with no kcd command issued in between.
Also fixes a test-helper data race the shorter interval exposed:
countingSender counted packets in a plain int while the plugin's real
D-Bus watcher can discover a live player and broadcast concurrently.
Trade-off worth stating plainly: with an MPRIS app open but paused the
daemon now does one D-Bus read per 10s, so "zero timers at idle" becomes
"zero timers when no MPRIS app is tracked". Playing remains 7.50
CPU ticks/min.
The 10s watchdog added with the stranded-poller fix means an MPRIS
player that is tracked but paused costs one D-Bus read per 10s. Every
doc still promised "nothing is polled while paused" and "zero
standing timers", which is no longer true on a desktop with a media
player merely running.
ARCHITECTURE.md gains a dedicated subsection for the one deliberate
exception and explains why it exists, the contributor invariant now
covers self-healing checks, and the anchor, CLIENT_GUIDE, and example
config all describe the real behaviour.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v1.20.0: idle/wake-ups, AUR source package, MPRIS reliability
Connected steady state with no MPRIS player running now uses zero application timers.
Measured on a live daemon via
/procCPU deltas anddbus-monitorcall rates — Go timers are runtime-managed, so there are no timerfds to count.No changes to wire format, protocol, TLS, or pairing behavior, and no plugin contract changes. Everything new is config-gated with safe defaults, so existing configs upgrade untouched.
Wake-up reductions
MPRIS: local poller only runs while something is playing (2a047c1)
Removed the standing 2s ticker along with dead hash-comparison code. The position ticker now exists only while at least one local player reports
IsPlaying.PosAnchorMsis stamped on every local broadcast and exposed on debug status. New config:[mpris] poll_while_playing = true,position_interval = "2s".Remote poller gated on active watchers (04a49de)
Added
Bus.OnSubscriberChange. The remote ticker itself only exists while someone subscribes tompris.update— not merely suppressed per tick.Reconnect is now sighting-driven instead of timer-driven (68c64b5)
Redial parks on a per-device wake channel. A sighting (UDP/mDNS) triggers an immediate dial against the freshest known address; if that fails, backoff escalates to
fallback_max(1h) and gives up paststale_after(24h) until the next sighting. Also fixed a goroutine leak where unpairing didn't close the parked channel. New config:[reconnect] sighting_driven = true,fallback_max = "1h",stale_after = "24h".mDNS browse now runs only while something owns it (f260062)
Periodic probes (4s → 60s, plus a 10s cleanup pass, source-verified) only run while pairing or reconnect needs them. Also fixed a leaked results goroutine and a double-close panic when an owned browse was cancelled.
New
keepalive_idleknob (586b706)Default stays 30s, floor is 10s, wired through the listener, outbound dial, and side-channel paths.
MPRIS reliability — found by live testing, not by CI
Deploying the idle work and actually playing music exposed a deadlock the unit tests could not: the position poller only armed from the cached
IsPlayingflag, but that cache is refreshed only by a signal or by the poller itself. Lose onePlaybackStatusedge and the poller stayed down for the rest of the session — the phone's now-playing froze while audio played.kcdcommand in between.Deliberate trade-off: while an MPRIS player is tracked but paused, the daemon performs one D-Bus read per 10s. "Zero timers at idle" therefore means zero when no MPRIS player is tracked, not zero on a desktop with a media player merely running. ~6 reads/min is a fair price for not freezing the display. Set
poll_while_playing = falseto remove both the poller and the watchdog.Packaging / infra
kcdAUR source package (28f9c64), already live as1.19.1-1. Added anaur_sourcesentry and generalizedaur-push.shso it picks up future tags automatically.flake.lockwith a container-verified vendor hash (3183ab5).install.shderives its version from the newest release tag instead ofgit describe, which labelled a post-release branch with a pre-release tag (2e2dd81).just cleanno longer deletes trackedpackaging/sources; the pairing verification code is returned to the CLI;kcd run listreturns a list instead of dropping the phone's reply; skipping a paused player no longer force-plays; one device-resolution rule across commands; fish completions brought back in sync (8c55cd1..baa21f5).Verification
Every commit:
gofmt,vet, full test suite,-raceon touched packages,golangci-lintclean, static binary build,goreleaser check. Integration tests now run on pull requests too — they previously only ran on the post-mergemainpush, so a feature branch never exercised them. The daemon was deployed and driven live throughout: idle and playing CPU, phone reconnect, arm/disarm transitions, and the stranded-poller recovery were all confirmed by hand. AUR source package went through a fullmakepkgbuild andnamcapclean.