Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 18 additions & 1 deletion lib/elixir_torrent_web_ui/engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<div>
<div id={"#{@id}-message"} class="min-w-0 wrap-anywhere">
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
Expand Down
8 changes: 4 additions & 4 deletions lib/elixir_torrent_web_ui_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, []}
Expand All @@ -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
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
1 change: 1 addition & 0 deletions priv/macos/src/AppDelegate+DockMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions priv/macos/src/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AppDelegate: NSObject {
private let port = Int(ProcessInfo.processInfo.environment["PORT"] ?? "4000") ?? 4000
var dockTorrents: [DockTorrent] = []
var dockRefreshTask: Task<Void, Never>?
let sleepPreventer = SleepPreventer()
private var openedFromURL = false
private lazy var server = ServerLifecycle(
dataDirectory: dataDirectory,
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions priv/macos/src/ServerLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
106 changes: 106 additions & 0 deletions priv/macos/src/SleepPreventer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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

/// 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).
private static func hasIncompleteTorrent(_ torrents: [DockTorrent]) -> Bool {
torrents.contains { $0.status != "Seeding" }
}

/// 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")
}
}
1 change: 1 addition & 0 deletions priv/scripts/macos/build-macos-dmg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
49 changes: 49 additions & 0 deletions test/elixir_torrent_web_ui/engine_eta_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading