From f668124e1c00ebc53c92d7bcb56e3442c43983e3 Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Mon, 31 Aug 2026 15:26:53 +0300 Subject: [PATCH 1/8] deps: take elixir_torrent 0.6.6 and refresh the rest to latest 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 --- mix.exs | 4 ++-- mix.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 2271789..c23b68c 100644 --- a/mix.exs +++ b/mix.exs @@ -74,7 +74,7 @@ defmodule ElixirTorrentWebUI.MixProject do {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 1.0"}, {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.2.0"}, + {:dns_cluster, "~> 0.3.0"}, {:bandit, "~> 1.5"} ] end @@ -86,7 +86,7 @@ defmodule ElixirTorrentWebUI.MixProject do # ELIXIR_TORRENT_PATH=../ElixirTorrent mix phx.server defp elixir_torrent_dep do case System.get_env("ELIXIR_TORRENT_PATH") do - nil -> {:elixir_torrent, "~> 0.6.5"} + nil -> {:elixir_torrent, "~> 0.6.6"} path -> {:elixir_torrent, path: path} end end diff --git a/mix.lock b/mix.lock index 4049d81..9dc2c17 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,7 @@ "certifi": {:hex, :certifi, "2.17.0", "835748414307e15e05b17d0e518190228ce648b08d569a5cc93a85a40f3e5c9b", [:rebar3], [], "hexpm", "8122798a17f0293c80daada25d0f81c7f4d708c73fef782c7c9b1950e26e4d21"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, - "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "dns_cluster": {:hex, :dns_cluster, "0.3.0", "5064e20cbde3b9a974671d85d996227d6dcc9e570b7a80ed464a2b01fc06a513", [:mix], [], "hexpm", "088428d2d128f8b278ad057d9167fcad221885a5129d0b246c42ed1ae7efc752"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "elixir_torrent": {:hex, :elixir_torrent, "0.6.6", "108fcc95b7003b11fb0d41064cdbc4492eb3a3660ec7c00c761df78e07080167", [:mix], [{:bento, "~> 1.0.0", [hex: :bento, repo: "hexpm", optional: false]}, {:httpoison, "~> 3.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:logger_backends, "~> 1.0", [hex: :logger_backends, repo: "hexpm", optional: false]}, {:logger_file_backend, "~> 0.0.14", [hex: :logger_file_backend, repo: "hexpm", optional: false]}, {:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "7a4719089c8e07d98932d941cd5b0fed9e1c2b6b055cef85918991b66a9dc0fb"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, From 87b899f4706c76cf1a82d77f05dfa1d28aab883e Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Mon, 31 Aug 2026 15:43:59 +0300 Subject: [PATCH 2/8] fix(ui): keep long torrent names inside the flash toast 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 --- .../components/core_components.ex | 2 +- .../components/core_components_test.exs | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/elixir_torrent_web_ui_web/components/core_components_test.exs diff --git a/lib/elixir_torrent_web_ui_web/components/core_components.ex b/lib/elixir_torrent_web_ui_web/components/core_components.ex index 2f1f727..170f245 100644 --- a/lib/elixir_torrent_web_ui_web/components/core_components.ex +++ b/lib/elixir_torrent_web_ui_web/components/core_components.ex @@ -69,7 +69,7 @@ defmodule ElixirTorrentWebUIWeb.CoreComponents do ]}> <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> -
+

{@title}

{msg}

diff --git a/test/elixir_torrent_web_ui_web/components/core_components_test.exs b/test/elixir_torrent_web_ui_web/components/core_components_test.exs new file mode 100644 index 0000000..8ed6c1a --- /dev/null +++ b/test/elixir_torrent_web_ui_web/components/core_components_test.exs @@ -0,0 +1,54 @@ +defmodule ElixirTorrentWebUIWeb.CoreComponentsTest do + use ElixirTorrentWebUIWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias ElixirTorrentWebUIWeb.CoreComponents + + @unbreakable "Some.Release.Name.Without.Any.Spaces.2026.2160p.WEB-DL.DDP5.1.HDR.x265-GROUP" + + setup do + ElixirTorrentWebUI.Locale.put("en") + :ok + end + + for kind <- [:info, :error] do + test "#{kind} flash keeps an unbreakable long message inside the toast" do + kind = unquote(kind) + + classes = + render_component(&CoreComponents.flash/1, + kind: kind, + flash: %{Atom.to_string(kind) => "Torrent added: #{@unbreakable}"} + ) + |> message_container(kind) + |> LazyHTML.attribute("class") + |> List.first() + |> String.split() + + assert "wrap-anywhere" in classes + assert "min-w-0" in classes + end + end + + test "flash still renders the full message text" do + html = + render_component(&CoreComponents.flash/1, + kind: :info, + flash: %{"info" => "Torrent added: #{@unbreakable}"} + ) + + text = + html + |> message_container(:info) + |> LazyHTML.text() + + assert text =~ @unbreakable + end + + defp message_container(html, kind) do + html + |> LazyHTML.from_fragment() + |> LazyHTML.query("#flash-#{kind}-message") + end +end From c6816c1da913e06863b3ff47959d1962338b08cb Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 17:29:38 +0300 Subject: [PATCH 3/8] fix(packaging): rotate server.log at launch instead of growing it forever `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 --- priv/macos/src/ServerLifecycle.swift | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/priv/macos/src/ServerLifecycle.swift b/priv/macos/src/ServerLifecycle.swift index f5c72d3..3f5e633 100644 --- a/priv/macos/src/ServerLifecycle.swift +++ b/priv/macos/src/ServerLifecycle.swift @@ -2,6 +2,8 @@ import Darwin import Foundation actor ServerLifecycle { + private static let maxServerLogBytes: UInt64 = 20 * 1024 * 1024 + private var ownedProcess: Process? private let dataDirectory: URL private let releaseBinary: URL @@ -170,6 +172,8 @@ actor ServerLifecycle { private func openServerLog() -> FileHandle? { let logURL = dataDirectory.appendingPathComponent("server.log") + rotateServerLogIfNeeded(at: logURL) + do { if !FileManager.default.fileExists(atPath: logURL.path) { FileManager.default.createFile(atPath: logURL.path, contents: nil) @@ -184,6 +188,35 @@ actor ServerLifecycle { } } + /// The release appends to `server.log` for the life of the install, so + /// without this the file grows without bound. Rotating only at launch is + /// enough: the running server holds the descriptor, and renaming a file + /// out from under an open descriptor would leave the new one empty. + private func rotateServerLogIfNeeded(at logURL: URL) { + let manager = FileManager.default + + guard + let attributes = try? manager.attributesOfItem(atPath: logURL.path), + let size = attributes[.size] as? UInt64, + size >= Self.maxServerLogBytes + else { + return + } + + let rotatedURL = logURL.appendingPathExtension("1") + + do { + if manager.fileExists(atPath: rotatedURL.path) { + try manager.removeItem(at: rotatedURL) + } + + try manager.moveItem(at: logURL, to: rotatedURL) + launcherLog("Rotated server log at \(size) bytes to \(rotatedURL.lastPathComponent)") + } catch { + launcherLog("Could not rotate server log at \(logURL.path): \(error)") + } + } + private func listenerPIDs(on port: Int) -> [pid_t] { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") From c95e2ebf7f09932162e69757a48306f28375f5a0 Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 17:29:51 +0300 Subject: [PATCH 4/8] fix(log): stop logging the launcher's Dock poll at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/elixir_torrent_web_ui_web/endpoint.ex | 8 ++++---- .../endpoint_log_level_test.exs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/elixir_torrent_web_ui_web/endpoint.ex b/lib/elixir_torrent_web_ui_web/endpoint.ex index e1fd17b..547d374 100644 --- a/lib/elixir_torrent_web_ui_web/endpoint.ex +++ b/lib/elixir_torrent_web_ui_web/endpoint.ex @@ -44,7 +44,7 @@ defmodule ElixirTorrentWebUIWeb.Endpoint do # The macOS launcher polls `GET /api/torrents` every two seconds to keep the # Dock menu current. At the default `:info` that is two lines per poll — # tens of megabytes of chatter in the packaged app's `server.log`. Routine - # polls log at `:debug`; every other request keeps `:info`. + # polls are not logged at all; every other request keeps `:info`. plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint], log: {__MODULE__, :request_log_level, []} @@ -62,10 +62,10 @@ defmodule ElixirTorrentWebUIWeb.Endpoint do @doc """ Per-request log level for `Plug.Telemetry`. - Only the launcher's Dock poll is demoted — `POST /api/torrents` adds a + Only the launcher's Dock poll is silenced — `POST /api/torrents` adds a torrent and stays at `:info`, as does everything else. """ - @spec request_log_level(Plug.Conn.t()) :: Logger.level() - def request_log_level(%Plug.Conn{method: "GET", path_info: ["api", "torrents"]}), do: :debug + @spec request_log_level(Plug.Conn.t()) :: Logger.level() | false + def request_log_level(%Plug.Conn{method: "GET", path_info: ["api", "torrents"]}), do: false def request_log_level(%Plug.Conn{}), do: :info end diff --git a/test/elixir_torrent_web_ui_web/endpoint_log_level_test.exs b/test/elixir_torrent_web_ui_web/endpoint_log_level_test.exs index 176db36..4df303b 100644 --- a/test/elixir_torrent_web_ui_web/endpoint_log_level_test.exs +++ b/test/elixir_torrent_web_ui_web/endpoint_log_level_test.exs @@ -13,10 +13,10 @@ defmodule ElixirTorrentWebUIWeb.EndpointLogLevelTest do :ok end - test "the launcher's Dock poll is demoted below the production log level" do + test "the launcher's Dock poll is not logged at any level" do conn = %Plug.Conn{method: "GET", path_info: ["api", "torrents"]} - assert Endpoint.request_log_level(conn) == :debug + assert Endpoint.request_log_level(conn) == false end test "requests that are not the Dock poll keep the default level" do @@ -40,11 +40,11 @@ defmodule ElixirTorrentWebUIWeb.EndpointLogLevelTest do refute logs =~ "Sent 200" end - test "the same poll is still visible when debugging", %{conn: conn} do + test "the same poll stays silent in a debug build", %{conn: conn} do logs = capture_log([level: :debug], fn -> get(conn, ~p"/api/torrents") end) - assert logs =~ "GET /api/torrents" - assert logs =~ "Sent 200" + refute logs =~ "GET /api/torrents" + refute logs =~ "Sent 200" end test "ordinary requests still log at the production level", %{conn: conn} do From 2da26f67b0a73ed40c6b9185ec23bef7aa727357 Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 17:29:51 +0300 Subject: [PATCH 5/8] feat(build): keep debug call sites in an opt-in diagnostic build `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 --- config/prod.exs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/config/prod.exs b/config/prod.exs index 75c8327..e5b8f9f 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -18,12 +18,20 @@ unless System.get_env("ELIXIR_TORRENT_DESKTOP_BUILD") == "1" do ] end -# Do not print debug messages in production -config :logger, - level: :info, - compile_time_purge_matching: [ - [level_lower_than: :info] - ] +# Do not print debug messages in production. +# +# A diagnostic build (`ELIXIR_TORRENT_DEBUG_BUILD=1 mix mac.dmg`) keeps the +# `Logger.debug` call sites instead of purging them at compile time, so the +# level can still be raised back to `:info` over RPC without a rebuild. +if System.get_env("ELIXIR_TORRENT_DEBUG_BUILD") == "1" do + config :logger, level: :debug +else + config :logger, + level: :info, + compile_time_purge_matching: [ + [level_lower_than: :info] + ] +end # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. From 85aa65bf40342e669bfc92345fa91d7acd2c36a0 Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 17:30:06 +0300 Subject: [PATCH 6/8] feat(macos): hold a sleep assertion while a torrent is downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- priv/macos/src/AppDelegate+DockMenu.swift | 1 + priv/macos/src/AppDelegate.swift | 4 + priv/macos/src/SleepPreventer.swift | 98 +++++++++++++++++++++++ priv/scripts/macos/build-macos-dmg.sh | 1 + test/macos/launcher_integration_test.exs | 2 + 5 files changed, 106 insertions(+) create mode 100644 priv/macos/src/SleepPreventer.swift diff --git a/priv/macos/src/AppDelegate+DockMenu.swift b/priv/macos/src/AppDelegate+DockMenu.swift index 13966a2..6013de5 100644 --- a/priv/macos/src/AppDelegate+DockMenu.swift +++ b/priv/macos/src/AppDelegate+DockMenu.swift @@ -62,6 +62,7 @@ extension AppDelegate { while !Task.isCancelled { if let torrents = await Self.refreshDockTorrents(endpoint: torrentsEndpoint) { dockTorrents = torrents + sleepPreventer.update(torrents: torrents) } try? await Task.sleep(nanoseconds: 2_000_000_000) } diff --git a/priv/macos/src/AppDelegate.swift b/priv/macos/src/AppDelegate.swift index feeb0ab..d0e5dc2 100644 --- a/priv/macos/src/AppDelegate.swift +++ b/priv/macos/src/AppDelegate.swift @@ -15,6 +15,7 @@ final class AppDelegate: NSObject { private let port = Int(ProcessInfo.processInfo.environment["PORT"] ?? "4000") ?? 4000 var dockTorrents: [DockTorrent] = [] var dockRefreshTask: Task? + let sleepPreventer = SleepPreventer() private var openedFromURL = false private lazy var server = ServerLifecycle( dataDirectory: dataDirectory, @@ -201,6 +202,9 @@ extension AppDelegate: NSApplicationDelegate { } func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { + dockRefreshTask?.cancel() + sleepPreventer.releaseForShutdown() + Task { await server.shutdown(port: port) NSApp.reply(toApplicationShouldTerminate: true) diff --git a/priv/macos/src/SleepPreventer.swift b/priv/macos/src/SleepPreventer.swift new file mode 100644 index 0000000..a48235f --- /dev/null +++ b/priv/macos/src/SleepPreventer.swift @@ -0,0 +1,98 @@ +import Foundation +import IOKit.ps +import IOKit.pwr_mgt + +/// Holds `kIOPMAssertPreventUserIdleSystemSleep` while a torrent still needs +/// bytes and the machine is on AC power. +/// +/// Without it the engine simply stops making progress unattended. Measured on +/// 2026-09-12: macOS put this Mac into `'Maintenance Sleep':TCPKeepAlive=active` +/// repeatedly overnight, waking only for 45-second DarkWake slices, so the node +/// accumulated **64 minutes of awake time in 7 hours** and five incomplete +/// torrents moved 19 MB between them. Sleeping is worse than just lost time: +/// every peer TCP connection dies with it, so each wake has to re-dial the swarm +/// from cold — the `[peer_dial] fail … reason=:etimedout` burst after each wake. +/// +/// Two deliberate limits, both chosen by the owner: +/// * **Seeding does not hold the assertion.** Once everything is complete the +/// laptop is allowed to sleep; otherwise a finished queue would keep it +/// awake indefinitely. +/// * **Battery does not hold it either.** The assertion is dropped as soon as +/// the charger comes out, so this can never flatten the battery. +@MainActor +final class SleepPreventer { + private var assertionID: IOPMAssertionID = 0 + private var isHeld = false + + /// `downKbps` cannot be the trigger even though it reads like the natural + /// one: the API reports `0.0` for torrents that are demonstrably + /// progressing, because the underlying counter is piece-granular and the + /// sample window is shorter than one piece (engine `PLAN.md` open bug #53b). + /// Gating on it would release the assertion exactly when a slow torrent + /// needs the machine awake most. `status` is authoritative. + private static func hasIncompleteTorrent(_ torrents: [DockTorrent]) -> Bool { + torrents.contains { $0.status == "Downloading" } + } + + /// A machine with no battery (desktop) reports AC, which is what we want. + /// An unreadable power source is treated as AC too: the failure mode of + /// guessing wrong here is a laptop that stays awake, which is recoverable, + /// versus a download queue that silently never finishes, which is the bug + /// this class exists to fix. + private static func isOnACPower() -> Bool { + guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let source = IOPSGetProvidingPowerSourceType(snapshot)?.takeUnretainedValue() + else { + return true + } + + return (source as String) == kIOPSACPowerValue + } + + /// Re-evaluated on every Dock refresh tick (2 s), so the assertion follows + /// both the queue and the power source without a timer of its own. + func update(torrents: [DockTorrent]) { + if Self.hasIncompleteTorrent(torrents) && Self.isOnACPower() { + acquire() + } else { + release() + } + } + + /// Released explicitly on quit — an assertion outlives the process that made + /// it only until the port closes, and leaving it to chance would leave the + /// Mac awake after the app is gone. + func releaseForShutdown() { + release() + } + + private func acquire() { + guard !isHeld else { return } + + var id: IOPMAssertionID = 0 + let result = IOPMAssertionCreateWithName( + kIOPMAssertPreventUserIdleSystemSleep as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + "ElixirTorrent is downloading" as CFString, + &id + ) + + guard result == kIOReturnSuccess else { + launcherLog("Could not create sleep assertion: IOReturn \(result)") + return + } + + assertionID = id + isHeld = true + launcherLog("Holding sleep assertion: downloading on AC power") + } + + private func release() { + guard isHeld else { return } + + IOPMAssertionRelease(assertionID) + assertionID = 0 + isHeld = false + launcherLog("Released sleep assertion") + } +} diff --git a/priv/scripts/macos/build-macos-dmg.sh b/priv/scripts/macos/build-macos-dmg.sh index 3f1f764..e771818 100755 --- a/priv/scripts/macos/build-macos-dmg.sh +++ b/priv/scripts/macos/build-macos-dmg.sh @@ -60,6 +60,7 @@ echo "==> Compiling native Dock launcher…" swiftc "$ROOT"/priv/macos/src/*.swift \ -o "$APP/Contents/MacOS/${EXECUTABLE_NAME}" \ -framework AppKit \ + -framework IOKit \ -swift-version 6 \ -O chmod +x "$APP/Contents/MacOS/${EXECUTABLE_NAME}" diff --git a/test/macos/launcher_integration_test.exs b/test/macos/launcher_integration_test.exs index cb6019e..7205a2e 100644 --- a/test/macos/launcher_integration_test.exs +++ b/test/macos/launcher_integration_test.exs @@ -79,6 +79,8 @@ defmodule ElixirTorrentWebUI.MacOS.LauncherIntegrationTest do binary, "-framework", "AppKit", + "-framework", + "IOKit", "-swift-version", "6", "-O" From db453dd93fca6fd6c5e185e3900cd018536d32e2 Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 17:30:14 +0300 Subject: [PATCH 7/8] fix(ui): cap the ETA instead of printing an astronomical number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/elixir_torrent_web_ui/engine.ex | 19 ++++++- .../elixir_torrent_web_ui/engine_eta_test.exs | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 test/elixir_torrent_web_ui/engine_eta_test.exs diff --git a/lib/elixir_torrent_web_ui/engine.ex b/lib/elixir_torrent_web_ui/engine.ex index 6a36993..508b008 100644 --- a/lib/elixir_torrent_web_ui/engine.ex +++ b/lib/elixir_torrent_web_ui/engine.ex @@ -449,6 +449,20 @@ defmodule ElixirTorrentWebUI.Engine do end) end + # Past this horizon an ETA is noise, and `format_eta/1` renders a concrete figure + # for it — reported from the UI as a day count of order 1e39 on a torrent whose + # rate had decayed to ~1e-39 KB/s, since `left / (kbps * 1024)` has no upper + # bound. qBittorrent caps its own ETA at the same 100 days and shows ∞ beyond, + # for the same reason. The rate source was fixed separately (engine `PLAN.md` + # #53b); this is the guard that keeps *any* near-zero rate from printing a + # nonsense number. + @max_eta_seconds 8_640_000 + + @doc false + @spec compute_eta_for_test(String.t(), non_neg_integer(), number(), non_neg_integer()) :: + nil | :infinity | float() + def compute_eta_for_test(status, left, kbps, peers), do: compute_eta(status, left, kbps, peers) + @spec compute_eta(String.t(), non_neg_integer(), number(), non_neg_integer()) :: nil | :infinity | float() defp compute_eta("Seeding", _left, _kbps, _peers), do: nil @@ -457,7 +471,10 @@ defmodule ElixirTorrentWebUI.Engine do defp compute_eta(_status, _left, kbps, _peers) when kbps <= 0, do: :infinity defp compute_eta(_status, left, kbps, _peers) do - left / (kbps * 1024) + case left / (kbps * 1024) do + seconds when seconds > @max_eta_seconds -> :infinity + seconds -> seconds + end end @spec hash_from_hex_id(String.t()) :: {:ok, Torrent.hash()} | {:error, :invalid_torrent} diff --git a/test/elixir_torrent_web_ui/engine_eta_test.exs b/test/elixir_torrent_web_ui/engine_eta_test.exs new file mode 100644 index 0000000..538a4a3 --- /dev/null +++ b/test/elixir_torrent_web_ui/engine_eta_test.exs @@ -0,0 +1,49 @@ +defmodule ElixirTorrentWebUI.EngineETATest do + use ExUnit.Case, async: true + + alias ElixirTorrentWebUI.Engine + + # `left / (kbps * 1024)` has no upper bound, and `format_eta/1` prints a concrete + # figure for whatever it gets. Reported from the UI as a day count of order 1e39 + # on a torrent whose rate had decayed to ~1e-39 KB/s. + @max_eta_seconds 8_640_000 + @left 462_422_016 + + describe "compute_eta/4" do + test "a near-zero rate reports infinity instead of an astronomical number" do + # The exact shape that was reported: a rate small enough that the quotient + # overflows any sane horizon. + assert Engine.compute_eta_for_test("Downloading", @left, 1.0e-39, 3) == :infinity + end + + test "anything past the horizon is infinity" do + # Chosen so left/(kbps*1024) lands just above the cap. + kbps = @left / (@max_eta_seconds * 1024) * 0.99 + + assert Engine.compute_eta_for_test("Downloading", @left, kbps, 3) == :infinity + end + + test "a realistic slow rate still reports a real estimate" do + # 10 KB/s on 462 MB is ~12.5 hours — slow, but a number the user can act on, + # so it must not be swallowed by the cap. + eta = Engine.compute_eta_for_test("Downloading", @left, 10.0, 3) + + assert is_float(eta) + assert eta < @max_eta_seconds + assert_in_delta eta, @left / (10.0 * 1024), 1.0 + end + + test "a fast rate is unaffected" do + eta = Engine.compute_eta_for_test("Downloading", @left, 1000.0, 8) + + assert_in_delta eta, @left / (1000.0 * 1024), 1.0 + end + + test "the pre-existing zero, no-peer, seeding and complete cases are unchanged" do + assert Engine.compute_eta_for_test("Downloading", @left, 0.0, 3) == :infinity + assert Engine.compute_eta_for_test("Downloading", @left, 50.0, 0) == :infinity + assert Engine.compute_eta_for_test("Seeding", 0, 0.0, 3) == nil + assert Engine.compute_eta_for_test("Downloading", 0, 50.0, 3) == nil + end + end +end From 6405bb296892f30f02df60159690ff5c896f1d4e Mon Sep 17 00:00:00 2001 From: Daniel Urumov Date: Sat, 12 Sep 2026 18:48:26 +0300 Subject: [PATCH 8/8] fix(macos): hold the sleep assertion for any non-seeding torrent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- priv/macos/src/SleepPreventer.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/priv/macos/src/SleepPreventer.swift b/priv/macos/src/SleepPreventer.swift index a48235f..ba3f9a6 100644 --- a/priv/macos/src/SleepPreventer.swift +++ b/priv/macos/src/SleepPreventer.swift @@ -24,14 +24,22 @@ final class SleepPreventer { private var assertionID: IOPMAssertionID = 0 private var isHeld = false + /// Anything that is not seeding still needs the machine awake, which is why + /// this is `!= "Seeding"` rather than `== "Downloading"`. The engine derives + /// the status from the piece currently being fetched, so an incomplete + /// torrent reports `"Connecting"` or `"Idle"` whenever no piece is assigned + /// — and 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. + /// Letting the Mac sleep there would strand it exactly when re-dialling is + /// the only thing that can rescue it. This also matches the Dock menu, which + /// files every non-seeding torrent under "Downloading:". + /// /// `downKbps` cannot be the trigger even though it reads like the natural /// one: the API reports `0.0` for torrents that are demonstrably /// progressing, because the underlying counter is piece-granular and the /// sample window is shorter than one piece (engine `PLAN.md` open bug #53b). - /// Gating on it would release the assertion exactly when a slow torrent - /// needs the machine awake most. `status` is authoritative. private static func hasIncompleteTorrent(_ torrents: [DockTorrent]) -> Bool { - torrents.contains { $0.status == "Downloading" } + torrents.contains { $0.status != "Seeding" } } /// A machine with no battery (desktop) reports AC, which is what we want.