Skip to content

Keep the Mac awake while downloading, and stop the log and ETA from lying - #9

Merged
daniboybye merged 8 commits into
mainfrom
fix/sleep-assertion-and-log-hygiene
Sep 14, 2026
Merged

daniboybye merged 8 commits into
mainfrom
fix/sleep-assertion-and-log-hygiene

Conversation

@daniboybye

Copy link
Copy Markdown
Owner

Five changes to the packaged macOS app. The headline one is that the app never asked macOS to stay awake, so unattended downloads barely progressed; the rest are the log hygiene and display bugs found while diagnosing it.

The Mac was asleep, and nothing inside the BEAM could see it

An overnight run read like an engine disaster: throughput down from 1.7 MB/s to ~1.6 KB/s (19 MB in 3¼ hours), one torrent frozen for two hours, and an apparent uptime of 64 minutes on a build deployed 7 hours earlier — which looked like two silent restarts.

None of it was an engine fault. server.log was continuous with no restart marker, and :erlang.statistics(:wall_clock) is derived from monotonic time, which on macOS does not advance across system sleep. It reports awake time, not uptime. pmset -g log showed the Mac in repeated 'Maintenance Sleep':TCPKeepAlive=active all night, waking only for 45-second DarkWake slices, one sleep stretch lasting 88 minutes: 64 minutes of awake time in 7 hours. The reason=:etimedout peer-dial burst after each wake is the expected consequence, since sleep kills every peer TCP connection and each wake has to re-dial the swarm from cold.

grep -rn 'caffeinate\|IOPMAssertion\|beginActivity\|NSProcessInfo' over the Swift and Elixir sources returned nothing: no power assertion had ever been taken. The only one pmset attributed to the app was an incidental 1-second CFNetwork assertion created by runningboardd.

SleepPreventer.swift now holds kIOPMAssertPreventUserIdleSystemSleep, driven off the existing 2-second Dock refresh poll rather than a timer of its own, and released explicitly in applicationShouldTerminate. It permits display sleep — only the system stays awake, which is what a background transfer needs.

Three deliberate choices:

  • Only on AC power. A download is not worth draining a battery the user is not watching, and IOPSGetProvidingPowerSourceType answers this cheaply. A machine with an unreadable power source is treated as AC, since a desktop is the common case there.
  • Seeding does not hold it. Once everything is complete the laptop is allowed to sleep; otherwise a finished queue would keep it awake indefinitely.
  • The gate is status == "Downloading", not downKbps > 0. The speed readout is piece-granular and reads 0.0 during normal operation, so gating on it would drop the assertion between pieces — exactly the moments the transfer needs the machine awake.

The remaining time displayed a number of order 1e39 days

Reported from the UI. compute_eta/4 returns left / (kbps * 1024) with no upper bound and format_eta/1 prints a concrete figure for whatever it gets, so any near-zero-but-non-zero rate produces a value that reads as corrupted rather than as an estimate. The rate in question had decayed to about 1e-39 KB/s.

The rate is fixed on the engine side, but the formatter should not depend on the rate being sane: anything past 100 days is now :infinity, which already renders as the infinity sign. qBittorrent caps its own ETA at the same horizon for the same reason. A genuinely slow estimate — 12 hours on 462 MB at 10 KB/s — still reports a real number, because that is actionable and the cap must not swallow it.

Log hygiene

server.log is rotated at launch. openServerLog appended with seekToEndOfFile and never rotated, so the file grew unbounded for the life of the install; a diagnostic build made it acute at roughly 72 KB/s and a 73 MB log was found in place. Rotation happens at launch only, and that is the load-bearing detail: the running release holds the descriptor, so renaming the file out from under it leaves the live file empty while the app keeps writing to an unlinked inode. One generation at a 20 MiB threshold.

The launcher's Dock poll is no longer logged at all. It hits GET /api/torrents every two seconds. Demoting the pair of lines to :debug was not enough, because a diagnostic build does not purge :debug and the poll then dominates the capture at 43 200 requests a day. Plug.Telemetry accepts false as a level. Only the poll is silenced; POST /api/torrents and everything else keep :info.

An opt-in diagnostic build keeps the debug call sites. compile_time_purge_matching removes every Logger.debug call site from the release, which is right for a shipped app and makes the packaged build impossible to diagnose — raising the level over RPC finds nothing left to enable. ELIXIR_TORRENT_DEBUG_BUILD=1 mix mac.dmg keeps them. The default path is unchanged.

Verification

Every gate of web-build-test-analyze.yml and the checkable gates of build-macos.yml were run locally, in CI's order, before pushing. All green:

Gate Result
mix test 143 passed (+5 for the ETA cap), 3 excluded
mix test --only macos_integration 3 passed — this compiles priv/macos/src/*.swift for real, so it covers the new IOKit link
coverage 63.4%
swiftlint lint --strict 0 violations
mix dialyzer 0 errors
mix credo --all 565 mods/funs, no issues
mix sobelow --skip --strict --private --exit --threshold medium clean
mix format --check-formatted clean
mix compile --warnings-as-errors (dev + test) clean
mix hex.audit, mix deps.unlock --check-unused clean
mix mac.dmg builds, signs and packages

Deployed and verified live on the packaged app: pmset -g assertions shows pid 92513(ElixirTorrentWebUI): PreventUserIdleSystemSleep named: "ElixirTorrent is downloading" held by our own process, distinct from the incidental runningboardd one. ETAs read 1h 37m and 7h 10m on the two downloading torrents instead of an astronomical figure.

Validated by inspection only, not live: the release-on-battery and release-when-all-complete paths of the assertion, since both need a hardware or queue state change to exercise.

Not reproducible locally: build-windows.yml, and the Codecov upload.

Note that mix.lock was reconciled during the rebase: two Dependabot merges on main landed older versions than this branch already carried (phoenix 1.8.12 against 1.8.13, phoenix_live_view 1.2.10 against 1.2.11, phoenix_pubsub 2.2.0 against 2.3.0). The newer set was kept and re-validated with mix deps.get --check-locked; bandit was 1.12.5 on both sides, so nothing was downgraded.

@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@daniboybye

Copy link
Copy Markdown
Owner Author

Correcting the sleep-assertion claim in this PR's description before anyone relies on it.

The assertion is held, but it does not fix the stalls it was written for. I measured again, this time reading the reason column:

2026-09-11 23:22:55  Sleep  Entering Sleep state due to 'Clamshell Sleep'
2026-09-12 18:03:19  Sleep  Entering Sleep state due to 'Clamshell Sleep'

Every entry after each of those is 'Maintenance Sleep', which is the periodic wake/sleep cycle of an already sleeping Mac rather than a fresh decision. The lid was closed. No user-space power assertion prevents clamshell sleep on Apple Silicon. My original diagnosis read only the 'Maintenance Sleep' lines and never asked what put the machine to sleep to begin with.

Second problem, independent of the first: this host has sleep 0 on AC (pmset -g custom), so idle sleep is already disabled system-wide when plugged in. Because the policy here is deliberately AC-only, the assertion is taken exactly where it cannot have an effect, and withheld on battery, where sleep 6 means idle sleep genuinely does happen. pmset -g shows it registered and inert:

sleep  0 (sleep prevented by Cursor, powerd, ElixirTorrentWebUI, Claude)

I am leaving the code in rather than reverting. It is correct, costs one assertion re-evaluated on an existing 2-second poll, releases properly — verified from powerd's own log, including the previous process releasing on graceful quit — and it would matter on a Mac whose AC idle sleep is enabled, which is the default on plenty of configurations. What was wrong is the claim, not the implementation.

What actually helps on this machine: keep the lid open while downloading, or sudo pmset -a disablesleep 1, which is the only mechanism that defeats clamshell sleep and is a system-wide change requiring admin rights — not something this app should do silently.

Worth building, but a separate decision: the app can detect this state (it holds an assertion, yet monotonic time has not advanced) and tell the user that the download stalled because the Mac slept, rather than leaving them to conclude the engine is broken.

daniboybye and others added 8 commits September 14, 2026 09:32
Every dependency with an available update moves to its newest release, so
the packaged app ships the current Phoenix/LiveView security and bug fixes
instead of trailing them.

dns_cluster needed its requirement widened to ~> 0.3.0; the 0.3.0 API is
compatible with our single call site, which passes :ignore in every
configuration but a DNS_CLUSTER_QUERY deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
daisyUI's `.alert` is a column grid, so the message track cannot shrink
below its min-content width. Torrent names are usually one long token
without spaces, which made min-content wider than the fixed toast and
pushed the text out past the right edge instead of wrapping. `text-wrap`
alone cannot help, because there are no word boundaries to break on.

`wrap-anywhere` lets the text break mid-token, which also collapses
min-content so the grid track fits inside the toast.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ever

`openServerLog` appended with `seekToEndOfFile` and never rotated, so the file
grew unbounded for the life of the install. A diagnostic build made it acute at
roughly 72 KB/s; a 73 MB log was found in place.

Rotation happens **at launch only**, and that is the load-bearing detail: the
running release holds the descriptor, so renaming the file out from under it
leaves the live file empty while the app keeps writing to an unlinked inode. One
generation (`server.log.1`) at a 20 MiB threshold is enough to keep the previous
session's tail for diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>
The macOS launcher polls `GET /api/torrents` every two seconds to keep the Dock
menu current. Demoting those two lines per poll to `:debug` was not enough: a
release purges `:debug` at compile time, but a diagnostic build does not, and the
poll then dominates the capture and hides real signal at 43 200 requests a day.

`Plug.Telemetry` accepts `false` as a level, which suppresses the pair outright.
Only the poll is silenced — `POST /api/torrents` adds a torrent and everything
else keeps `:info`.

Co-authored-by: Cursor <cursoragent@cursor.com>
`config :logger, compile_time_purge_matching: [[level_lower_than: :info]]` removes
every `Logger.debug` call site from the release, which is right for a shipped app
and makes the packaged build impossible to diagnose: raising the level over RPC
finds nothing left to enable.

`ELIXIR_TORRENT_DEBUG_BUILD=1 mix mac.dmg` now keeps the call sites, so the level
can be moved at runtime without a rebuild. The default path is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
The app took no power assertion, so macOS idle-slept the Mac underneath an active
transfer. Measured overnight: 64 minutes awake in a 7-hour window, with the engine
suspended for the rest. This is invisible from inside the BEAM because
`:erlang.statistics(:wall_clock)` counts awake time only — monotonic time does not
advance across system sleep, so uptime, timers and rate windows all look normal
while nothing is on the wire. It reads as a swarm problem: peers time out en
masse on resume because the TCP connections died while we were not running.

`SleepPreventer` takes `kIOPMAssertPreventUserIdleSystemSleep` while any torrent
is `Downloading` and releases it otherwise, driven off the Dock refresh loop that
already polls every two seconds — no new timer, and the assertion follows the same
state the Dock menu shows. It is released explicitly in
`applicationShouldTerminate` rather than relying on process exit.

Two deliberate choices:

  * The assertion is taken **only on AC power**. A download is not worth draining
    a battery the user is not watching, and `IOPSGetProvidingPowerSourceType`
    answers this cheaply. A machine with an unreadable power source is treated as
    AC, since a desktop is the common case there.
  * The gate is `status == "Downloading"`, not `downKbps > 0`. The speed readout is
    piece-granular and reads 0 during normal operation (engine `PLAN.md` #53b), so
    gating on it would drop the assertion between pieces — the exact moments the
    transfer needs to stay alive.

This is a display-sleep-permitting assertion: the screen still sleeps, only the
system stays awake, which is what a background transfer needs.

`IOKit` is now linked in both the packaging script and the launcher integration
test, which compiles the same sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reported from the UI on a torrent showing a remaining time of order 1e39 days.
`compute_eta/4` returns `left / (kbps * 1024)` with no upper bound and
`format_eta/1` prints a concrete figure for whatever it gets, so any near-zero but
non-zero rate produces a number that reads as a corrupted value rather than an
estimate. The rate in question had decayed to ~1e-39 KB/s; that source is fixed
separately in the engine, but the formatter should not depend on the rate being
sane.

Anything past 100 days is now `:infinity`, which already renders as `∞`.
qBittorrent caps its own ETA at the same horizon for the same reason. A genuinely
slow estimate — 12 hours on 462 MB at 10 KB/s — still reports a real number,
because that is actionable and the cap must not swallow it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The gate was `status == "Downloading"`, which is narrower than the intent. The
engine derives that status from the piece currently being fetched
(`Peer.status()` is `nil | :seed | :connecting_to_peers | index`), so an
**incomplete** torrent reports `"Idle"` or `"Connecting"` whenever no piece is
assigned to it. Behind CGNAT, where a torrent runs on one to three peers, that is
a state it passes through constantly while hunting for somewhere to ask — and
letting the Mac sleep there strands it exactly when re-dialling is the only thing
that can rescue it, because sleep drops every peer connection it might have been
about to establish.

`!= "Seeding"` is the same test the Dock menu already applies when it files
torrents under its "Downloading:" heading, so the assertion and the menu now agree
on what counts as active. There is no paused or stopped status to worry about
letting through: `Peer.status()` has no such value, and a stopped torrent is not
in the list at all.

Found by reading `powerd`'s own log after deploying the original commit —
`log show --predicate 'eventMessage CONTAINS "ElixirTorrentWebUI"'` shows every
acquire and release with an age, which is a better instrument for this than
`pmset -g assertions` because it shows the transitions rather than the current
state. It recorded a release-and-reacquire on a torrent that never stopped being
incomplete. Forty consecutive two-second samples afterwards all read
`"Downloading"`, so this specific flap is not reproduced on demand — but the code
path is plainly there, and the conservative gate has no upside on AC power.

Co-authored-by: Cursor <cursoragent@cursor.com>
@daniboybye
daniboybye force-pushed the fix/sleep-assertion-and-log-hygiene branch from cfb1d70 to 6405bb2 Compare September 14, 2026 06:32
@daniboybye
daniboybye merged commit 5c3e038 into main Sep 14, 2026
2 checks passed
@daniboybye
daniboybye deleted the fix/sleep-assertion-and-log-hygiene branch September 14, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant