diff --git a/.greptile/rules.md b/.greptile/rules.md index 8feaa9a5..5ae6fd9c 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -42,17 +42,44 @@ Before the backstop's `lowpowermode 0`, when the journal has the app doubts that entry's readings in that boot even if the journal of the undo never lands. If that publish fails, the mode is left on and its entry kept for retry. Both scripts check the records' types and read the -three keys from the file's text too (`record_text_problems`, the same in -both), as the app's decoder reads it: keys of the top-level object only, -with their `\u` escapes decoded, every value stepped over whole, so a -string or nested value holds no record. Each number must be null or a -JSON number that a Swift Float holds and that does not round to 0 from a -nonzero value (plutil turns 1e-400 into 0.0). One of the keys found twice -at the top level, a key with an escape JSON does not have, and a top -level the reader cannot follow (JSON5 keys, comments, NUL bytes as in -UTF-16) are refused. Any of -these makes the journal malformed, so nothing is undone and uninstall -removes nothing. `savedAudioOutputs` entries alone leave the +whole file's text too (`record_text_problems`, the same in both), as the +app's decoder reads it: every object and array at any depth, keys with +their `\u` escapes decoded (a Kelvin sign read as K), strings stepped over +whole, so a saved name holds no key. A key written twice counts by its +first copy, as in the app's decoder. A number where the app reads a Float +must not round to infinity, or to 0 from a nonzero value, compared as +exact decimal digits rather than through plutil's Double; one where it +reads an Int32 or Int64 must be a whole number the type holds as the app +reads it (`5105.0`, `1e3`, `1e-400` as 0). Where plutil would read the +file otherwise than the app (a later copy of a key, an escaped key, such a +number, UTF-16 or UTF-32), the scripts read and edit a view of the journal +as the app reads it, which drops what the app's own save drops. An escape +JSON does not have, a value that is no JSON value and text the app's +decoder refuses (JSON5 keys, comments) are refused, and so is a NUL byte +in a string the app reads, as the app refuses it; one in text the app +skips is left out of the view. A `\u0000` escape in a string the app +reads, which plutil cannot hold, is held in the view as a placeholder no +string of the journal holds (json_held_token), and every journal +published from the view must have that string's own text back once, where +it was, or it is not published. Refused too: a journal for which no +placeholder can be had (grep fails), and text the reader does not finish +within 30 s (very many keys or a very long array). Any refusal makes the +journal malformed, so nothing is undone and uninstall removes nothing. Values are checked only where the +app reads them: in a frozen process, `startedAtMicros` only after a +`startedAt` that is there and not null, and `bootSession` only after both, +as `FrozenProcess` decodes them. A `sessionCutoffs` the app reads as no +record (not a string of the form it writes) is a record the app does not +write (read as none), not a malformed journal. When the app's binary cannot +answer for `config.json`, backstop.sh reads that file with the same reader +in its config form, which also checks each value's type (`config_cutoffs`), +and uses it only when the reader finds nothing it cannot settle: not a file +over 8 MiB, one it does not finish within 30 s, or one on which Foundation +stops the app. So a hand edit reaches the backstop on that path only in a +form the reader reads as the app does. backstop.sh +checks the journal so before it reads the cutoffs for a valid session or +ends one: a journal that fails, or that `--agent-session-cutoffs` answers +`rejected` for, stops the run with `session.json`, the journal and every +undo entry kept. `savedAudioOutputs` entries alone leave the journal clean for the backstop (an entry can wait days for its device), but uninstall stops on them. A saved brightness flagged `displayRestoreRefused` or `keyboardRestoreRefused` (the app's private-call @@ -86,11 +113,42 @@ known key of the wrong type is left untouched and the run exits 1. The app and the script serialize on one `flock(2)` lock, `.recovery.lock`, which is never unlinked so both lock the same inode -(`RecoveryLock.swift`; `lockf` on fd 9 in the scripts). `uninstall.sh` +(`RecoveryLock.swift`; `lockf` on fd 9 in the scripts). It may also hold a +record of a session's end (`ended-session-v1 `), written in place +through the held descriptor and never by replacing the file. Both writers +cut the file back to bytes it shares with the start of the record, or to +nothing, before they append the rest, so a stop partway never leaves the +record's first bytes over old bytes that differ. That record cut short as +a writer leaves it (its first bytes, or the whole record with the file's +old bytes after it) counts as the end of the session whose bytes it starts +with, and no writer empties it; other content that is no record ends +nothing, and the app empties it before a resumed session goes on. A start +settles the file before it writes session.json +(`Store.settleLockForStart`): a stale record's first bytes would end the +new session too. An unreadable lock file is never written over. Both sides +read it three times, 0.1 s apart (`Store.lockReadAttempts`, +`LOCK_READ_ATTEMPTS`), before it counts as unreadable, and then it counts +as the end of the session in session.json: a rule for the safe side, not +proof of an end, since content that is no record but keeps failing to read +ends a live session. When insomnia.log holds that session's record, the log +is named as where the end is recorded. When the lock file write fails too, +the end is appended to +`insomnia.log` as one line (`insomnia-ended-session-v1 `, +`LogEndRecord.swift`). Every writer of the log in this repo (the app's +`OwnerOnly.appendToLog` and `LogEndRecord`, backstop.sh's `log` and +`record_end_in_log`, the LaunchAgent program) holds flock(2) on the log +from its look at the last byte until its write ends and puts a newline +first when the log does not end in one, so no line of theirs joins a +record; a process that appends without that lock still can. insomnia.log +is rotated only under the recovery lock and the log's flock, with a record +still in force copied forward. `uninstall.sh` takes the lock, runs the backstop with `--force` under it, and refuses to -remove the recovery machinery while anything is still journaled. Battery -and thermal floors run only while the app is alive; the backstop does not -provide them. +remove the recovery machinery while anything is still journaled. The Low +Power Mode requests for battery and thermal run only while the app is +alive. The ends do not: the backstop ends a valid session below the end +floor on battery power, on a battery it cannot read and at critical +thermal pressure, once a minute, as well as at its deadline and once the +app is gone. ## Deliberate designs, do not flag @@ -179,7 +237,7 @@ Flag a change that breaks one of these; do not flag the behavior itself. except that a backstop run gives `keptDisplayUnderLowPowerBoot` its own boot, in a journal it publishes before its `lowpowermode 0`, and leaves the mode on if it cannot. The records are read from the file's text as - well as through plutil (`record_text_problems`), at the top level only. + well as through plutil (`record_text_problems`), which reads every object. Legacy `frozenPids` are never signaled or cleared there, even when the pid is gone (spec section 8). - `ProcessControl.swift`, `LidActions.swift`, `backstop.sh`. Only pids @@ -220,6 +278,9 @@ Flag a change that breaks one of these; do not flag the behavior itself. `insomnia.log`, a local file shared with `backstop.sh` so one file tells the whole story. That file may contain SSIDs, process metadata and tmux target names (SECURITY.md). The privacy rule applies to the unified log. + A line written without the recovery lock is never followed by a rotation + (`OwnerOnly.LogRotation.deferred`), so the file can pass 1 MiB until a + line is written under the lock: it may hold an end record. - `LidActions.swift`. Lid events do nothing when no session is active (spec section 3). - `simulate-lid.sh`, `LidSimulation.swift`. The trigger file is the same diff --git a/README.md b/README.md index 351f7cd0..00a6e04e 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,9 @@ On battery power, Insomnia turns on Low Power Mode below 40% and ends the session below 10%. Those are the defaults. You can change both in Settings, and an end floor of 0 turns the battery end off. The heat rules turn on Low Power Mode when the Mac gets seriously hot and end the session at critical heat. -They are on by default, and Settings can turn them off. All of these rules -only work while the app runs. +They are on by default, and Settings can turn them off. Low Power Mode needs +the app to run. The two ends don't, since the recovery agent also checks them +once a minute. Before your first session, check Settings. Then run a short session you can watch, and read `~/Library/Logs/Insomnia/insomnia.log` afterward. @@ -152,18 +153,20 @@ and how to test lid actions without closing the lid. ## How recovery works

- The app and a launchd backstop coordinate through a shared lock and recovery journal. The app handles normal cleanup. The backstop checks every minute and attempts due recovery, leaving valid active sessions alone. Failed or unreadable recovery evidence stays on disk; saved audio needs the app and unconfirmed stopped processes need inspection. + The app and a launchd backstop coordinate through a shared lock and recovery journal. The app handles normal cleanup. The backstop checks every minute: it attempts recovery once the deadline has passed, and ends a session early when the app is gone, the battery is below the end floor or the Mac is critically hot. Failed or unreadable recovery evidence stays on disk; saved audio needs the app and unconfirmed stopped processes need inspection.

Insomnia writes each change to a **recovery journal**, a file on disk, before it makes the change. Ending the session tries to undo what the journal lists. -If the app crashes or quits, the recovery agent tries the same once the -session's end time has passed. +The recovery agent checks once a minute. It ends the session and tries the +same undo when the app is gone, the end time has passed, the battery is below +the end floor or the Mac is critically hot. Some things need you: - The recovery agent can't restore audio. Open Insomnia again for that. -- The recovery agent doesn't watch the battery or the temperature. +- Without the app, nothing turns on Low Power Mode. The recovery agent still + ends the session at the battery end floor and at critical heat. - Insomnia only unfreezes a process it can prove it froze. A frozen process it can't prove stays frozen until you check it. diff --git a/Resources/Info.plist b/Resources/Info.plist index 3f212a64..567105ad 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -36,6 +36,8 @@ NSApplication InsomniaResumeFrozenVersion 1 + InsomniaAgentCutoffsVersion + 3 NSHumanReadableCopyright Copyright © 2026 Krish Garg. MIT License. diff --git a/SECURITY.md b/SECURITY.md index 3bcbf397..b6ecd19a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,16 +13,56 @@ the sensitive details, and wait for the maintainer to arrange one. The installer grants the user account passwordless access to four exact pmset commands listed in the README. This grant is not exclusive to the Insomnia app: -other processes running as that user can invoke them too. The app is not +other processes running as that user can invoke them too. The liveness lock +the backstop probes (`.app.alive` in Application Support) is an flock(2) any +process running as that user can hold; a process holding it stops the backstop +from noticing that Insomnia has quit. Only that check is lost: the backstop +still ends the session at its deadline, below the battery end floor, or at +critical thermal pressure. While the lock is held, the backstop reads the end +floor and the thermal rule by passing `config.json` to the installed app's +binary (`Insomnia --agent-cutoffs`), the one in the bundle whose signature the +agent checks before each run, and while that file is missing or rejected, the +values the app recorded for the session in `state.json` +(`Insomnia --agent-session-cutoffs`). When that binary is gone or replaced by +another version during the run, or gives no answer in its form in time, the +backstop reads `config.json` itself where its own reader can tell what the +app's decoder makes of it (not for a file over 8 MiB, one the reader does +not finish within 30 s, or one on which Foundation stops the app), and +otherwise the values recorded in `state.json`, once the journal passes its +check. A recorded value the app does not write counts +as none, as the app reads it. With no record, it enforces the app's defaults +(a 10% end floor, thermal rules on) while `config.json` is missing, not a +regular file, not readable by this user, or rejected, and the strictest values +(95%, on) while it is a regular file this user can read and neither the +binary nor the backstop's reader can tell what the app makes of it; both are +open stopgaps (spec section 6). A journal the app would not load, or one whose meaning to the app +the check cannot tell (an Int64 on which Foundation stops the app, text it +does not finish reading within 30 s, a string the app reads that holds +`\u0000` when the system's `grep` fails, so that no placeholder for it can be +had), stops the run with the session and the journal kept. An edited journal +replaces `state.json` only when it loads as the app loads it, keeps every +`Float` the app decodes bit for bit, and has each string that holds `\u0000` +back once, in its own entry under its own key; otherwise the run keeps the +old journal, with what it still has to undo, and exits 1. The app is not sandboxed; local logs can contain SSIDs, process metadata, and tmux target names. The lines the app writes to `insomnia.log` also reach the unified log with the body marked private, so programs reading `log show` see `` instead of those names unless private data logging is enabled on the Mac. The files -Insomnia creates (logs, journal, session, config, recovery lock) are mode 0600 +Insomnia creates (logs, journal, session, config, recovery lock, session end +records) are mode 0600 and its directories 0700; the backstop runs with `umask 077`. Insomnia sets only these modes and leaves any access control list (ACL) on these files as it is, so an ACL someone added can still give another account access. Logs are capped at -1 MiB with one older copy kept. A log the user replaced with a symlink is not +1 MiB with one older copy kept. `insomnia.log` is rotated only while the app +holds the recovery lock, because it can hold the record of a session's end (a +line with session.json's bytes in base64), so it can grow past 1 MiB until +then. Insomnia's writers of `insomnia.log` (the app, the backstop and its +LaunchAgent) hold flock(2) on the file while they write, so no line of +theirs lands inside such a record. Any process running as that user can +hold that lock too: the app's lines then wait in memory (64 KiB), +the backstop's go to standard error, and no record of a session's end is +written there. A process that appends without the lock can still break a +record that was already read back. A log the user replaced with a symlink is not rotated: the file it points to is the user's to manage. Location Services access is requested only when a hotspot is saved or a session starts with one configured; it is used to read Wi-Fi network names and the app never diff --git a/Sources/Insomnia/Core/AgentCutoffsCommand.swift b/Sources/Insomnia/Core/AgentCutoffsCommand.swift new file mode 100644 index 00000000..12cde430 --- /dev/null +++ b/Sources/Insomnia/Core/AgentCutoffsCommand.swift @@ -0,0 +1,145 @@ +import Darwin +import Foundation + +/// `Insomnia --agent-cutoffs `, config.json's bytes on standard input +/// `Insomnia --agent-session-cutoffs `, state.json's bytes on standard input +/// +/// One-shot modes for backstop.sh. While the app is alive the agent still +/// enforces the end floor and the thermal rule itself, for an app that has +/// stopped answering, and it must enforce the ones the app takes from the +/// same file. So it does not read config.json a second way: it opens the +/// file once, passes the bytes on standard input, and this mode decodes +/// them with the app's own decoder (`Store.decodeConfig`, which +/// `Store.loadConfig` runs) and prints `Config.agentCutoffs`, what the app +/// adopts from a file that decodes (`adoptConfigFileCutoffs`). Duplicate or +/// escaped keys, numbers the decoder rounds, and errors in any other field +/// therefore come out exactly as in the app. Nothing else runs: no AppKit, +/// no file opened, no setting written, no lock taken, no private API. +/// +/// `--agent-session-cutoffs` is for when config.json is missing, cannot be +/// read or is rejected. It first decodes the whole journal with the app's +/// own decoder (`Store.decodeState`, which `Store.loadState` runs). The app +/// does not start, extend or end a session on a journal that fails there, +/// and backstop.sh then keeps the session and the journal as they are. +/// Then it reads the journal's `sessionCutoffs` strictly +/// (`RuntimeState.decodeSessionCutoffs`) and prints the cutoffs the app +/// recorded for the session, so a hung app's session keeps the floor and +/// rule it had. +/// +/// `` is how long this process may live, a whole number from 1 to +/// `ResumeFrozenCommand.maxLifetimeSeconds`. As in `--resume-frozen`, it +/// arranges to end itself with SIGALRM that many seconds later before it +/// reads anything, so a caller that dies first cannot leave it waiting on +/// its input. The caller starts it with the recovery lock closed. +/// +/// Prints one line: +/// +/// cutoffs the bytes decode (and, for the +/// journal, record cutoffs); exit 0 +/// none the journal records none (one an +/// older build wrote); exit 0 +/// rejected the app's decoder rejects them +/// (the journal: all of it, as the +/// app loads it); exit 65 +/// foreign the journal decodes, but its +/// sessionCutoffs is a value the app +/// does not write, which the app +/// reads as none; exit 65 +/// unreadable standard input failed, or held more +/// than `maxInputBytes`; exit 74 +/// usage bad arguments, nothing read; exit 64 +/// +/// backstop.sh enforces the printed cutoffs. On `rejected` from +/// config.json it asks for the journal's; on `none` or `foreign` it +/// enforces the app's defaults (`Config.agentDefaultCutoffs`), since the +/// app reads a foreign value as none; on `rejected` from the journal it +/// stops without ending the session. When the binary gives no answer for +/// config.json, the script reads that file itself where it can tell +/// exactly what this decoder makes of it, and otherwise the journal's +/// cutoffs (see read_cutoffs there). +/// +/// The bundle declares this interface as `InsomniaAgentCutoffsVersion` +/// (`version`) in its Info.plist. backstop.sh runs the binary only when the +/// installed bundle declares the version the script speaks, so it never +/// starts an older build, which would open the menu bar app instead. +enum AgentCutoffsCommand { + static let flag = "--agent-cutoffs" + static let sessionFlag = "--agent-session-cutoffs" + /// `InsomniaAgentCutoffsVersion` in Resources/Info.plist, and + /// AGENT_CUTOFFS_VERSION in backstop.sh. 2 added `sessionFlag`; 3 made + /// it decode the whole journal first and added `foreign`. + static let version = 3 + /// EX_USAGE, EX_DATAERR and EX_IOERR from sysexits(3). + static let usageStatus: Int32 = 64 + static let rejectedStatus: Int32 = 65 + static let unreadableStatus: Int32 = 74 + /// config.json and state.json are a few kilobytes. Anything past this is + /// not read on, so a huge file cannot make this process hold it all in + /// memory. + static let maxInputBytes = 8 << 20 + + /// nil when `arguments` (the command line without the executable) do + /// not ask for this mode. `endAfter` gets the lifetime once the + /// arguments are valid, before `input` is read (`ResumeFrozenCommand. + /// endProcess` in the binary). `input` returns nil when the bytes could + /// not all be read. + static func run( + _ arguments: [String], + input: () -> Data? = { readStandardInput(limit: maxInputBytes) }, + endAfter: (UInt32) -> Void + ) -> ResumeFrozenCommand.Output? { + guard let mode = arguments.first, mode == flag || mode == sessionFlag else { return nil } + guard arguments.count == 2, let seconds = ResumeFrozenCommand.lifetime(arguments[1]) else { + let file = mode == flag ? "config.json" : "state.json" + FileHandle.standardError.write(Data("usage: Insomnia \(mode) < \(file)\n".utf8)) + return .init(lines: ["usage"], status: usageStatus) + } + endAfter(seconds) + guard let data = input() else { return .init(lines: ["unreadable"], status: unreadableStatus) } + return mode == flag ? answer(for: data) : sessionAnswer(for: data) + } + + /// The answer for config.json's bytes. + static func answer(for data: Data) -> ResumeFrozenCommand.Output { + guard let config = try? Store.decodeConfig(data) else { + return .init(lines: ["rejected"], status: rejectedStatus) + } + return cutoffsOutput(config.agentCutoffs) + } + + /// The answer for state.json's bytes: `rejected` for a journal the app + /// cannot load, else the cutoffs the app recorded for the session, + /// through the reader the app uses for them, which picks duplicate and + /// escaped keys as the app's decoder does. + static func sessionAnswer(for data: Data) -> ResumeFrozenCommand.Output { + guard (try? Store.decodeState(data)) != nil else { + return .init(lines: ["rejected"], status: rejectedStatus) + } + guard let journal = try? Store.makeDecoder().decode(JournaledSessionCutoffs.self, from: data) else { + return .init(lines: ["foreign"], status: rejectedStatus) + } + guard let cutoffs = journal.cutoffs else { return .init(lines: ["none"], status: 0) } + return cutoffsOutput(cutoffs) + } + + private static func cutoffsOutput(_ cutoffs: AgentCutoffs) -> ResumeFrozenCommand.Output { + .init(lines: ["cutoffs \(cutoffs.journalValue)"], status: 0) + } + + /// Everything on fd 0 up to end of file, or nil when a read fails or + /// there are more than `limit` bytes. + static func readStandardInput(limit: Int) -> Data? { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 << 10) + while true { + let n = buffer.withUnsafeMutableBytes { read(0, $0.baseAddress, $0.count) } + if n == 0 { return data } + if n < 0 { + if errno == EINTR { continue } + return nil + } + data.append(contentsOf: buffer[0.. limit { return nil } + } + } +} diff --git a/Sources/Insomnia/Core/AppAliveLock.swift b/Sources/Insomnia/Core/AppAliveLock.swift new file mode 100644 index 00000000..8b7ede12 --- /dev/null +++ b/Sources/Insomnia/Core/AppAliveLock.swift @@ -0,0 +1,74 @@ +import Darwin +import Foundation + +/// Proof to backstop.sh that an Insomnia process is alive: an exclusive +/// flock(2) on `Paths.appAliveFile`, taken at launch before reconcile and +/// held until the process exits. The kernel drops the lock when the holder +/// dies, so a crash or force-quit is visible to the backstop on its next +/// run: it probes the lock without waiting (`lockf -t 0`) and ends a valid +/// session when it acquires it. The file is created 0600 and never unlinked, +/// so both sides lock the same inode. O_CLOEXEC: children (pmset, osascript, +/// tmux) must not inherit the lock and keep a session alive after the app +/// is gone. +/// +/// One lock per process is the point, so this is a class that owns the +/// descriptor rather than a handle factory like `RecoveryLock`. +final class AppAliveLock: @unchecked Sendable { + let path: String + private let mutex = NSLock() + private var fd: Int32 = -1 + + init(url: URL) { path = url.path } + + /// Whether this instance holds the lock right now. + var isHeld: Bool { mutex.withLock { fd >= 0 } } + + /// The held descriptor, for tests that inspect its flags; -1 when not held. + var fileDescriptor: Int32 { mutex.withLock { fd } } + + /// One attempt. False when another process (or another instance in this + /// one) holds it; true when held, including when it was already held by + /// this instance. Throws when the file cannot be opened or locked for + /// any other reason. + func tryAcquire() throws -> Bool { + try mutex.withLock { + if fd >= 0 { return true } + let opened = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0o600) + guard opened >= 0 else { throw RecoveryLockError.open(path: path, errno: errno) } + if flock(opened, LOCK_EX | LOCK_NB) == 0 { + fd = opened + return true + } + let err = errno + close(opened) + if err == EWOULDBLOCK { return false } + throw RecoveryLockError.lock(path: path, errno: err) + } + } + + /// Polls `tryAcquire` until it succeeds or `timeout` has passed, sleeping + /// between attempts so the calling actor is never blocked. The wait is + /// short on purpose: another holder is another Insomnia, or a backstop + /// probe that gives the lock back within milliseconds. False when the + /// lock is still held elsewhere at the end. + func acquire(timeout: TimeInterval, pollEvery: Duration = .milliseconds(100)) async throws -> Bool { + let deadline = ContinuousClock.now + .seconds(timeout) + while true { + if try tryAcquire() { return true } + guard ContinuousClock.now < deadline else { return false } + try await Task.sleep(for: pollEvery) + } + } + + /// Lets the lock go. Releasing twice is harmless; going away releases. + func release() { + mutex.withLock { + guard fd >= 0 else { return } + flock(fd, LOCK_UN) + close(fd) + fd = -1 + } + } + + deinit { release() } +} diff --git a/Sources/Insomnia/Core/LaunchGate.swift b/Sources/Insomnia/Core/LaunchGate.swift new file mode 100644 index 00000000..62daa8bf --- /dev/null +++ b/Sources/Insomnia/Core/LaunchGate.swift @@ -0,0 +1,51 @@ +import Foundation + +/// One Insomnia runs at a time: the process that holds the alive lock (see +/// AppAliveLock). backstop.sh reads that lock as proof that the app behind +/// a session is alive, so a copy without it must never own one: a session +/// it started would outlive its crash, because the backstop would still see +/// the other copy's lock. Launch Services keeps one instance, but `open -n` +/// or running the binary directly gives two. The copy that cannot take the +/// lock does nothing at all (no reconcile, no menu, no session, no end on +/// quit), says why, and quits. +@MainActor +struct LaunchGate { + let aliveLock: AppAliveLock + let notifier: any Notifying + /// Covers a backstop probe, which holds the lock for a moment. A hold + /// that outlasts it is another Insomnia. + var timeout: TimeInterval = 2 + + static let anotherCopyTitle = "Insomnia is already running" + static let lockFailedTitle = "Insomnia did not start" + + /// Takes the alive lock, then watches for output device changes, runs + /// `start` and reconciles. Taken before reconcile, so the first backstop + /// run after launch already sees this process, and never released: the + /// kernel drops it when the process exits, however that happens. Without + /// the lock none of these run (a device change during the wait or after + /// the refusal restores nothing), the user is told why, and the result + /// is false for the caller to quit; the notification has reached the + /// system by then. + func open(manager: SessionManager, start: () -> Void) async -> Bool { + let title: String + let why: String + do { + if try await aliveLock.acquire(timeout: timeout) { + Log.info("alive lock held") + manager.watchOutputDevices() + start() + await manager.reconcile() + return true + } + title = Self.anotherCopyTitle + why = "Another copy of Insomnia holds \(aliveLock.path), so this one quit without changing anything. Use the one in the menu bar." + } catch { + title = Self.lockFailedTitle + why = "Insomnia could not take its lock \(aliveLock.path) (\(error.localizedDescription)), so it cannot tell whether another copy is running. It quit without changing anything." + } + Log.error(why) + await notifier.postBeforeExit(title: title, body: why) + return false + } +} diff --git a/Sources/Insomnia/Core/LaunchdBackstop.swift b/Sources/Insomnia/Core/LaunchdBackstop.swift index b3dcd605..0e80a845 100644 --- a/Sources/Insomnia/Core/LaunchdBackstop.swift +++ b/Sources/Insomnia/Core/LaunchdBackstop.swift @@ -54,12 +54,19 @@ struct LaunchdBackstop: BackstopScheduling { /// codesign and execs the sealed backstop.sh only when that passes; the /// resource seal covers the script, so an edited copy fails here. On /// failure it appends one line to ~/Library/Logs/Insomnia/insomnia.log - /// (the LaunchAgent only ever exists in the standard layout) and exits 1 - /// without running anything. install.sh embeds this same text (its - /// AGENT_PROGRAM line); LaunchdBackstopTests checks the two are equal so - /// the app recognises the plist install.sh wrote. No single quotes, so - /// the shell can hold it in one. - static let agentProgram = #"r="$(/usr/bin/codesign --verify --strict "-R=$1" "$2" 2>&1)" && exec /bin/bash "$2/Contents/Resources/backstop.sh"; mkdir -p "$HOME/Library/Logs/Insomnia"; printf "%s [error] backstop agent: %s does not satisfy the pinned code requirement; backstop.sh not run. Reinstall Insomnia (scripts/install.sh). codesign: %s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$2" "$(printf %s "$r" | tr "\n" " ")" >> "$HOME/Library/Logs/Insomnia/insomnia.log"; exit 1"# + /// (the LaunchAgent only ever exists in the standard layout), after a + /// newline when the file ends in a line cut short or its last byte + /// cannot be read, and exits 1 without running anything. Like every + /// writer of that log it holds flock(2) on the file (`lockf` on its + /// descriptor, at most 5 s) from its look at the last byte until the + /// line is written, and opens the path again, up to three times, when + /// the file it locked is no longer the one the path names + /// (`OwnerOnly.lockLog`, `LogEndRecord`). A lock not taken drops the + /// line. install.sh embeds this same + /// text (its AGENT_PROGRAM line); LaunchdBackstopTests checks the two + /// are equal so the app recognises the plist install.sh wrote. No + /// single quotes, so the shell can hold it in one. + static let agentProgram = #"r="$(/usr/bin/codesign --verify --strict "-R=$1" "$2" 2>&1)" && exec /bin/bash "$2/Contents/Resources/backstop.sh"; mkdir -p "$HOME/Library/Logs/Insomnia"; f="$HOME/Library/Logs/Insomnia/insomnia.log"; m="$(printf "%s [error] backstop agent: %s does not satisfy the pinned code requirement; backstop.sh not run. Reinstall Insomnia (scripts/install.sh). codesign: %s" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$2" "$(printf %s "$r" | tr "\n" " ")")"; for t in 1 2 3; do { /usr/bin/lockf -s -t 5 8 || exit 1; i="$(/usr/bin/stat -f %d:%i <&8)"; [ -n "$i" ] && [ "$i" = "$(/usr/bin/stat -L -f %d:%i "$f" 2>/dev/null)" ] || continue; [ ! -s "$f" ] || [ "$(/usr/bin/tail -c 1 "$f" 2>/dev/null; echo x)" = "$(printf "\nx")" ] || printf "\n" >&8; printf "%s\n" "$m" >&8; exit 1; } 8>>"$f"; done; exit 1"# let plistURL: URL let bundle: URL diff --git a/Sources/Insomnia/Core/Log.swift b/Sources/Insomnia/Core/Log.swift index a0609cca..1ea1040b 100644 --- a/Sources/Insomnia/Core/Log.swift +++ b/Sources/Insomnia/Core/Log.swift @@ -12,6 +12,13 @@ import os enum Log { static let logger = Logger(subsystem: Paths.bundleIdentifier, category: "core") private static let lock = NSLock() + /// Lines that found insomnia.log locked by another writer for longer + /// than `OwnerOnly.logLockTimeout`, by log path, under `lock`. They go + /// out before the next line to that log, in order; the unified log has + /// them already. Past `maxDeferredBytes` the oldest whole lines are + /// dropped, so a log held for good costs no more memory than that. + private nonisolated(unsafe) static var deferred: [String: String] = [:] + static let maxDeferredBytes = 64 * 1024 static func info(_ message: String) { logger.info("\(message, privacy: .private)") @@ -23,19 +30,56 @@ enum Log { append(level: "error", message) } - static func append(level: String, _ message: String, paths: Paths = .fromEnvironment()) { + static func append( + level: String, + _ message: String, + paths: Paths = .fromEnvironment(), + lockTimeout: TimeInterval = OwnerOnly.logLockTimeout + ) { let now = Date().formatted(.iso8601.year().month().day().dateTimeSeparator(.standard).time(includingFractionalSeconds: false).timeZone(separator: .omitted)) let line = "\(now) [\(level)] insomnia: \(message)\n" + // insomnia.log can hold the record of a session's end, which + // backstop.sh writes and reads back under the recovery lock alone + // (`LogEndRecord`), so it is rotated only while this task's + // transaction holds that lock, and a record still in force is + // copied forward first. Without the lock the line goes to the file + // as it is; the next line written under the lock rotates it. + let rotation: OwnerOnly.LogRotation = RecoveryLock.held?.locks(path: paths.recoveryLock.path) == true + ? .keeping { LogEndRecord.keepRecords(in: $0, log: paths.logFile, session: paths.sessionFile) } + : .deferred lock.lock() defer { lock.unlock() } + let key = paths.logFile.path + let text = deferred.removeValue(forKey: key).map { $0 + line } ?? line do { // Owner-only, and rotated to insomnia.log.1 past OwnerOnly.maxLogBytes. // A chmod or rotation that fails is thrown after the line is // written, so it reaches the unified log below. - try OwnerOnly.appendToLog(line, at: paths.logFile) + try OwnerOnly.appendToLog(text, at: paths.logFile, rotation: rotation, lockTimeout: lockTimeout) } catch { + // Nothing was written: the lines wait for the next one. + if case .busy = error as? OwnerOnlyError { deferred[key] = lastLines(of: text, upTo: maxDeferredBytes) } // The description carries a path under the home directory. logger.error("insomnia.log: \(error.localizedDescription, privacy: .private)") } } + + /// The last whole lines of `text`, at most `limit` bytes in all. + private static func lastLines(of text: String, upTo limit: Int) -> String { + var kept = Substring(text) + while kept.utf8.count > limit { + guard let newline = kept.firstIndex(of: "\n") else { return "" } + kept = kept[kept.index(after: newline)...] + } + return String(kept) + } + + /// Runs `body` under the lock every line appended here takes, so no + /// line and no rotation from this process runs meanwhile. `body` must + /// not log. + static func withFileLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } } diff --git a/Sources/Insomnia/Core/RecoveryLock.swift b/Sources/Insomnia/Core/RecoveryLock.swift index 2daec938..6355658f 100644 --- a/Sources/Insomnia/Core/RecoveryLock.swift +++ b/Sources/Insomnia/Core/RecoveryLock.swift @@ -37,6 +37,14 @@ final class RecoveryLockHandle: @unchecked Sendable { private let mutex = NSLock() private var fd: Int32 + #if DEBUG + /// Tests stand in for pwrite(2) in `replaceContents`: given the + /// descriptor, the bytes still to write, their count and the offset, it + /// returns what pwrite(2) would. Debug builds only; nothing reads it + /// otherwise. + nonisolated(unsafe) static var pwriteForTesting: ((Int32, UnsafeRawPointer, Int, off_t) -> Int)? + #endif + fileprivate init(fd: Int32) { self.fd = fd } /// Closes this process's descriptor, as exiting would. No `LOCK_UN`: @@ -51,6 +59,21 @@ final class RecoveryLockHandle: @unchecked Sendable { } } + /// Whether this handle still holds the lock on the file at `path`: not + /// released, and `path` names the file its descriptor is open on. The + /// log's rotation and its end record (`LogEndRecord`) run only then, so + /// a handle inherited by a task that outlived its transaction, or one + /// on another home's lock, never counts. + func locks(path: String) -> Bool { + mutex.withLock { + guard fd >= 0 else { return false } + var held = stat() + var named = stat() + return fstat(fd, &held) == 0 && stat(path, &named) == 0 + && held.st_dev == named.st_dev && held.st_ino == named.st_ino + } + } + /// A new close-on-exec descriptor on the locked file, for a child that /// must keep the lock while it runs: the spawn installs it without the /// flag. The caller closes its copy once the child has been started. @@ -63,6 +86,73 @@ final class RecoveryLockHandle: @unchecked Sendable { } } + /// Makes the locked file hold exactly `data`, written in place through + /// this handle's descriptor, so the file keeps its inode and stays the + /// lock every party takes (`Store.recordSessionEndInLock`). Only while + /// `path` still names that same file, a regular file (lstat, so not a + /// symlink) this user owns. The bytes the file already shares with the + /// start of `data`, read through the same descriptor, are kept; the + /// file is cut to them, then the rest of `data` is appended. So a step + /// cut short leaves the old bytes untouched, or a prefix of `data` at + /// least as long as the part they shared: never `data`'s first bytes + /// over old bytes that differ. A record's first bytes count as its end + /// (`Store.lockHoldsRecordCutShort`), so the end of a session written + /// here counts from the moment the file changes, and its first bytes + /// already there only grow. A file that cannot be read through the + /// descriptor is left as it is. True once written and synced; the + /// caller reads the file back. False once released or when a step + /// fails, a write that writes no byte included. + func replaceContents(with data: Data, at path: String) -> Bool { + mutex.withLock { + guard fd >= 0 else { return false } + var held = stat() + var named = stat() + guard fstat(fd, &held) == 0, lstat(path, &named) == 0, + named.st_mode & S_IFMT == S_IFREG, named.st_uid == getuid(), + held.st_dev == named.st_dev, held.st_ino == named.st_ino else { return false } + let size = Int(held.st_size) + guard let kept = sharedPrefix(with: data, upTo: min(size, data.count)) else { return false } + if size > kept, ftruncate(fd, off_t(kept)) != 0 { return false } + var write: (Int32, UnsafeRawPointer, Int, off_t) -> Int = { pwrite($0, $1, $2, $3) } + #if DEBUG + if let injected = Self.pwriteForTesting { write = injected } + #endif + let written = data.withUnsafeBytes { raw -> Bool in + var offset = kept + while offset < raw.count { + let n = write(fd, raw.baseAddress! + offset, raw.count - offset, off_t(offset)) + if n < 0 { + if errno == EINTR { continue } + return false + } + // A write that moved nothing would be tried forever. + guard n > 0 else { return false } + offset += n + } + return true + } + return written && fsync(fd) == 0 + } + } + + /// How many of the file's first `count` bytes equal `data`'s, read + /// through the held descriptor; nil when they cannot be read. + private func sharedPrefix(with data: Data, upTo count: Int) -> Int? { + guard count > 0 else { return 0 } + var bytes = [UInt8](repeating: 0, count: count) + var read = 0 + while read < count { + let n = bytes.withUnsafeMutableBytes { pread(fd, $0.baseAddress! + read, count - read, off_t(read)) } + if n < 0 { + if errno == EINTR { continue } + return nil + } + if n == 0 { break } + read += n + } + return zip(bytes.prefix(read), data).prefix { $0 == $1 }.count + } + deinit { release() } } diff --git a/Sources/Insomnia/Core/SessionManager.swift b/Sources/Insomnia/Core/SessionManager.swift index 76b52a21..3adb43aa 100644 --- a/Sources/Insomnia/Core/SessionManager.swift +++ b/Sources/Insomnia/Core/SessionManager.swift @@ -11,6 +11,14 @@ enum EndReason: String, Sendable { case batteryUnreadable case thermalCritical case backstop + /// session.json was gone, or recorded as ended (in ended-session.json or + /// the journal's endedSession), while this process still held the + /// session: the recovery agent ended it (its log line says why: app not + /// running, battery below the end floor, critical heat) while the app + /// was stopped, hung, or not holding the alive lock, and restored from + /// the journal. + /// The app ends on its side from whatever the journal still holds. + case agentCutoff /// Reconcile found a session on disk but could not arm the recovery /// agent, journal, or hold sleep for it, so it ended the session instead /// of holding sleep with nothing to release it. @@ -19,6 +27,17 @@ enum EndReason: String, Sendable { /// may still have been applied, so the start is undone from the journal /// like an end rather than rolled back from memory. case startFailed + /// config.json could not be read and could not be moved aside, or is + /// missing and the settings in use, whose cutoffs differ from the + /// agent's defaults, could not be written in its place + /// (`rejectedConfigFile`). backstop.sh reads its cutoffs from that file + /// itself, so the session would run on cutoffs the app does not enforce. + case settingsFileRejected + /// The end floor and thermal rule in use could not be recorded for the + /// running session in state.json (`publishSessionCutoffs`). backstop.sh + /// enforces that record while config.json cannot be used, so a hung + /// app's session would run on cutoffs the app does not enforce. + case cutoffsNotRecorded } /// What `end` achieved. Callers that are about to quit need to know whether @@ -36,8 +55,9 @@ enum EndOutcome: Sendable, Equatable { /// session is still active. An in-process retry is scheduled. case locked /// session.json could not be removed. Whatever the journal held was - /// undone, but a relaunch would find a valid session and hold sleep - /// again, so the end is retried in process and quit is refused. + /// undone and the end is recorded in ended-session.json when that write + /// works, so a relaunch restores instead of resuming. The removal is + /// retried in process and quit is refused. case sessionRetained /// state.json cannot be read. Nothing was changed: Insomnia does not /// know what to undo and will not guess. The session stays active until @@ -372,9 +392,14 @@ final class SessionManager { private let display: any DisplayDimming private let keyboard: any KeyboardBacklighting private let appNap: any AppNapPreferencing - private let notifier: any Notifying + /// Not private: a copy that quits at launch (LaunchGate) posts through it. + let notifier: any Notifying private let clamshell: @Sendable () -> Bool? private let clock: @Sendable () -> Date + /// The manager's idea of now (a fake in tests). UI decisions about the + /// session the manager holds, such as how much an extension may still add + /// under `config.maxDuration`, must use this clock, not the wall clock. + var now: Date { clock() } /// What the process table says a pid is now. Read only, to tell whether /// a recorded command is still running (`recordedLockHolder`). private let processLookup: @Sendable (Int32) -> ProcessLookup @@ -410,6 +435,9 @@ final class SessionManager { /// device change, lid open, end or launch tries again. @ObservationIgnored private var audioRetriesLeft = SessionManager.audioRetryLimit static let audioRetryLimit = 10 + /// Set once `watchOutputDevices` has registered for CoreAudio's device + /// changes. + @ObservationIgnored private var watchingOutputDevices = false /// Called just before Insomnia takes Low Power Mode over, before the /// ownership is journaled: `AppServices` samples the display brightness /// then, so the value journaled at a later lid close is the user's, @@ -438,9 +466,30 @@ final class SessionManager { /// nil in tests. Started after a session starts, stopped when it ends. @ObservationIgnored var services: AppServices? + #if DEBUG + /// The points in a start where tests copy the files on disk, as a crash + /// there would leave them, or change them: once the lock file's bytes + /// are read, before it is settled, and after each write. Debug builds + /// only; nothing sets it otherwise. + enum StartStep { case lockFileRead, sessionWritten, journalWritten } + @ObservationIgnored var onStartStepForTesting: ((StartStep) -> Void)? + #endif + @ObservationIgnored private var deadlineTimer: Timer? @ObservationIgnored private var countdownTimer: Timer? @ObservationIgnored private var retryTimer: Timer? + @ObservationIgnored private var checkingAgentEnd = false + /// The tick's last read of the logs for an end record, with what it + /// read them at (`logRecordsSessionEnd`). + @ObservationIgnored private var lastLogEndRecordRead: (fingerprint: String, found: Bool)? + /// The tick's next look for the agent's end after a transaction for it + /// was refused: the recovery lock was held, state.json did not decode, + /// or an unfinished command held the lock. backstop.sh removes + /// session.json before its undo, so an undo command that hangs keeps + /// the lock past the end, and a journal stays unreadable until a person + /// repairs it. The tick then asks again after `recoveryRetryDelay`, not + /// on every second with a new lock wait and log line each time. + @ObservationIgnored private var agentEndRetryAt = Date.distantPast /// Whether the 1 Hz redraw is currently on the run loop. Tests assert on /// this to prove an idle session leaves no repeating wakeup behind. var countdownTimerArmed: Bool { countdownTimer != nil } @@ -530,6 +579,48 @@ final class SessionManager { case notASession } + /// What init found wrong with config.json, posted by the first + /// reconcile: init runs before the app has finished launching, and a + /// second copy that never takes the alive lock never reconciles. + @ObservationIgnored private var configNotice: String? + /// Why no session may run, set by every transaction while config.json + /// is there, the app rejects it, and it could not be moved aside, or + /// while it is missing and the settings in use cannot be written there + /// (`publishConfig`). backstop.sh reads the cutoffs from that file + /// through the app's decoder, and while it is missing or rejected, from + /// the journal's record for the session (`RuntimeState.sessionCutoffs`) + /// or else its own defaults. Sessions stay refused until the file holds + /// the settings in use, so no session rests on that record alone. Nil + /// once the file reads again or the settings in use are written where + /// it was. + @ObservationIgnored private(set) var rejectedConfigFile: String? + /// config.json was rejected and the settings in use are not yet written + /// in its place. Set when the file is moved aside, and also when it + /// cannot be: a person who then deletes it, as the refusal suggests, + /// would otherwise leave config.json missing, and the agent on the + /// journal's record or its defaults, while the app runs on its own + /// settings. Every transaction that finds no file tries the write + /// again. + @ObservationIgnored private var configWriteOwed = false + /// Why the last change Settings made to the end floor or the thermal + /// rules did not take effect: config.json could not be written + /// (`updateConfig`). Settings shows it under those controls. Nil once a + /// change is saved. + private(set) var configSaveError: String? + /// The last failure to write the settings in use where config.json is + /// missing, when the agent's defaults are the app's cutoffs and nothing + /// is refused; kept so each transaction does not log it again. + @ObservationIgnored private var configPublishFailure: String? + @ObservationIgnored private var checkingConfigFile = false + /// The tick's next look at config.json after a transaction left it + /// missing, unreadable or carrying other cutoffs (a write that failed, + /// a busy lock): `recoveryRetryDelay` later, not every second. + @ObservationIgnored private var configCheckRetryAt = Date.distantPast + /// Run by `updateConfig` after config.json refused a cutoff change and + /// before the journal's record of the old cutoffs is put back. + /// Injection point for tests that make that write fail. + @ObservationIgnored var beforeRecordedCutoffsPutBack: (() -> Void)? + init( paths: Paths, sleepGuard: any SleepGuarding, @@ -587,7 +678,28 @@ final class SessionManager { self.state = loadedState ?? .clean self.lowPowerWasOurs = loadedState.map { $0.lowPowerSetByUs && !$0.lowPowerClaimFromEarlierBoot(boot: bootSession) } ?? false self.lastError = loadError - if var c = (try? store.loadConfig()) ?? nil { + var loadedConfig: Config? + do { + loadedConfig = try store.loadConfig() + } catch { + // The file is the user's settings with something this build + // cannot decode (a hand edit's typo or wrong type), or it could + // not be read at all. Defaults written over it would lose those + // settings for good, so it is renamed aside first, and when the + // rename fails nothing is written over it. + var detail = error.localizedDescription + if case let StoreError.unreadable(_, brief) = error { detail = brief } + do { + let moved = try store.moveAsideUnreadableConfig(now: clock()) + Log.error("config.json could not be read (\(detail)); moved to \(moved.path); using default settings") + configNotice = "config.json could not be read (\(detail)). It was moved to \(moved.path), and Insomnia is using default settings. To get yours back, quit Insomnia, fix that file and rename it to config.json." + } catch let moveError { + Log.error("config.json could not be read (\(detail)) or moved aside (\(moveError.localizedDescription)); left in place; using default settings") + configNotice = Self.rejectedConfigMessage(paths.configFile.path, detail: detail, moveError: moveError.localizedDescription) + + " Meanwhile Insomnia uses default settings and left the file as it is." + } + } + if var c = loadedConfig { // Settings keeps the end floor below the Low Power Mode floor; a // hand-edited config.json may not. Fix it here and write it back. var corrections = c.normalizeFloors().map { [$0] } ?? [] @@ -610,13 +722,33 @@ final class SessionManager { } } self.config = c + // An older build's file was just read with its stock values + // migrated. Writing it back once marks it current, so a ceiling + // typed into it later is read as the user's. + if (try? store.configHasVersion()) == false { try? store.saveConfig(c) } self.pendingLidCloseNotice = lidClose } else { // A fresh install: the defaults already are the update, and // `Config()` carries its mark, so there is nothing to announce. + // Not written here: init runs before LaunchGate, and a second + // copy that the gate refuses would write its defaults over a + // config.json the running copy has yet to write back. The first + // transaction after the gate writes the settings in use + // (`checkConfigFile`); until then the agent's defaults are + // these. self.config = Config() - try? store.saveConfig(self.config) } + } + + /// Registers for CoreAudio's device changes, so an output device still + /// muted from a lid close gets its volume back when it reconnects + /// (`outputDevicesChanged`). LaunchGate calls it once this process holds + /// the alive lock, not init: a second copy of the app waiting at the + /// gate, or refused there, must not restore a device the copy that owns + /// the session muted. Registers once; later calls do nothing. + func watchOutputDevices() { + guard !watchingOutputDevices else { return } + watchingOutputDevices = true do { try audio.onDevicesChanged { [weak self] in Task { @MainActor in await self?.outputDevicesChanged() } @@ -686,12 +818,15 @@ final class SessionManager { /// `op` is not run at all when the lock cannot be taken within the /// bound or when state.json does not decode: nothing is read, decided /// or changed unlocked, and an unreadable journal is never overwritten. + /// With `syncSession` (every caller but `end`, which does this work + /// itself) a session the recovery agent has ended meanwhile is ended + /// here first, so `op` never acts on a session that is over on disk. /// Never blocks the main actor; the wait is polled. /// /// Refused while an unfinished command runs, `deferred` is recorded in /// the refusal itself, before the caller resumes: the command can exit /// and its holder settle in between, and must find the work then. - private func exclusive(_ what: String, owes deferred: Deferred? = nil, _ op: @escaping @MainActor @Sendable () async -> T) async -> Result { + private func exclusive(_ what: String, owes deferred: Deferred? = nil, syncSession: Bool = true, _ op: @escaping @MainActor @Sendable () async -> T) async -> Result { let previous = lifecycleTail let task = Task, Never> { @MainActor in await previous?.value @@ -738,9 +873,25 @@ final class SessionManager { self.writeOwedEdits() self.settleDisplayAfterUnreadOff() let before = self.unfinishedCommand - // Every `sudo pmset` `op` runs is handed this lock - // (`PmsetSleepGuard`) and holds it until it exits. - let result = await RecoveryLock.$held.withValue(handle) { await op() } + // Every `sudo pmset` this transaction runs, in an end below or + // in `op`, is handed this lock (`PmsetSleepGuard`) and holds it + // until it exits. + let result: Result = await RecoveryLock.$held.withValue(handle) { + if syncSession { await self.adoptAgentEnd() } + self.checkConfigFile() + if syncSession { + await self.endIfConfigFileRejected() + await self.publishSessionCutoffs() + } + // An end above that stopped at a sudo pmset left running + // ends the transaction there (`stopTransaction`): `op` must + // not change anything beside that command. The end is + // retried when it exits. + if let stuck = self.unfinishedCommand, stuck !== before { + return .failure(.commandRunning(pid: stuck.pid)) + } + return .success(await op()) + } if let stuck = self.unfinishedCommand, stuck !== before { // A sudo pmset this transaction ran did not stop on SIGTERM. // The lock goes with it, not with the transaction: the @@ -753,12 +904,358 @@ final class SessionManager { lockHandedOver = true self.holdLock(handle, until: stuck) } - return .success(result) + return result } lifecycleTail = Task { _ = await task.value } return await task.value } + /// Disk decides whether a session exists. session.json gone while this + /// process still holds a session means backstop.sh ended it (its log + /// line says why) while this process could not act: stopped, hung, or + /// without the alive lock. So does a session.json the agent recorded as + /// ended because it could not remove the file, in ended-session.json, + /// in the journal's endedSession, in a record aside + /// (ended-session.json.<8 letters or digits>), in the recovery lock + /// file (`Store.sessionEndRecordedInLock`), or in insomnia.log or + /// insomnia.log.1 (`Store.sessionEndRecordedInLog`). The agent has restored + /// what it could; the end here runs from the journal just read under the + /// lock, so anything it left is retried, and observers, timers and the + /// countdown stop. An unreadable session.json is not a vanished one and + /// is left alone. + private func adoptAgentEnd() async { + guard let s = session else { return } + let onDisk: Session? + do { + onDisk = try store.loadSession() + } catch { + return + } + if onDisk == nil { + Log.error("session.json is gone while the session until \(iso(s.endsAt)) was active: the recovery agent ended it (its log line says why); ending here from the journal") + } else if store.sessionEndIsRecorded() { + Log.error("the session until \(iso(s.endsAt)) is recorded as ended in ended-session.json: the recovery agent ended it but could not remove session.json (its log line says why); ending here from the journal") + } else if store.sessionEndIsJournaled(in: state) { + Log.error("the session until \(iso(s.endsAt)) is recorded as ended in state.json (endedSession): the recovery agent ended it but could not remove session.json or write ended-session.json (its log line says why); ending here from the journal") + } else if let record = store.sessionEndRecordAside() { + Log.error("the session until \(iso(s.endsAt)) is recorded as ended in \(record.lastPathComponent): the recovery agent ended it but could not remove session.json or write ended-session.json or state.json (its log line says why); ending here from the journal") + } else if let (place, inLog) = store.sessionEndRecordedInLockOrLog() { + if inLog { + Log.error("the session until \(iso(s.endsAt)) is recorded as ended in \(place): the recovery agent ended it but could not remove session.json or write ended-session.json, state.json, a new file or the recovery lock file (its log line says why); ending here from the journal") + } else { + Log.error("the session until \(iso(s.endsAt)) is recorded as ended in \(place): the recovery agent ended it but could not remove session.json or write ended-session.json, state.json or a new file (its log line says why); ending here from the journal") + } + } else { + return + } + endTicket += 1 + _ = await performEnd(reason: .agentCutoff) + } + + /// The rules for config.json, applied in every transaction, because + /// backstop.sh reads the file's endFloor and thermalRules on every run + /// and enforces them itself. Both sides must enforce the same cutoffs + /// before a session starts, resumes or goes on: + /// - A file that decodes is what the agent enforces, so its end floor + /// and thermal rule are taken into the settings in use when they + /// differ (a hand edit, a repair after a rejection); every other + /// setting stays as it is (`adoptConfigFileCutoffs`). + /// - A missing file (deleted, moved aside below, or never written) gets + /// the settings in use written in its place (`publishConfig`). + /// - A file the app rejects is moved aside and replaced the same way. + /// When the rename fails, `rejectedConfigFile` says why and no + /// session runs. + private func checkConfigFile() { + let detail: String + do { + guard let onDisk = try store.loadConfig() else { + publishConfig() + return + } + if onDisk.agentCutoffs != config.agentCutoffs { adoptConfigFileCutoffs(onDisk) } + if rejectedConfigFile != nil { Log.info("config.json reads again; sessions can start") } + rejectedConfigFile = nil + configWriteOwed = false + configPublishFailure = nil + return + } catch let StoreError.unreadable(_, brief) { + detail = brief + } catch { + detail = error.localizedDescription + } + do { + let moved = try store.moveAsideUnreadableConfig(now: clock()) + Log.error("config.json could not be read (\(detail)); moved to \(moved.path)") + configWriteOwed = true + publishConfig() + if let why = rejectedConfigFile { + notifier.post(title: Self.configFileTitle, body: "config.json could not be read (\(detail)) and was moved to \(moved.path). \(why)") + } else { + notifier.post(title: Self.configFileTitle, body: "config.json could not be read (\(detail)). It was moved to \(moved.path), and Insomnia wrote the settings it is using back to config.json.") + } + } catch let moveError { + let why = Self.rejectedConfigMessage(paths.configFile.path, detail: detail, moveError: moveError.localizedDescription) + if rejectedConfigFile != why { Log.error(why) } + rejectedConfigFile = why + configWriteOwed = true + } + } + + /// Takes the end floor and thermal rule of a config.json that decodes, + /// which the agent enforces, into the settings in use. The Low Power + /// Mode floor is raised above the new end floor if needed + /// (`normalizeFloors`); nothing else changes, and the file is not + /// rewritten. The floors run again so the change applies now. + private func adoptConfigFileCutoffs(_ onDisk: Config) { + let before = config.agentCutoffs + var c = config + c.endFloor = onDisk.endFloor + c.thermalRules = onDisk.thermalRules + let corrected = c.normalizeFloors() + config = c + Log.info("config.json has \(c.agentCutoffs.description), the app had \(before.description): the recovery agent enforces the file, so the app does too" + (corrected.map { "; \($0)" } ?? "")) + services?.reevaluateFloors() + } + + /// Writes the settings in use where config.json is missing. While it is + /// missing the agent enforces the cutoffs the journal records for the + /// session (`RuntimeState.sessionCutoffs`), or its own defaults + /// (`agentDefaultCutoffs`) when it records none. A write that fails + /// stops sessions (`rejectedConfigFile`) when the app's cutoffs differ + /// from those defaults, or when the file was rejected + /// (`configWriteOwed`): the settings the app fell back to are not in + /// config.json. Otherwise both enforce the same cutoffs, and the next + /// transaction writes again. + private func publishConfig() { + do { + try store.saveConfig(config) + Log.info("the settings in use were written to config.json" + (rejectedConfigFile != nil ? "; sessions can start" : "")) + configWriteOwed = false + rejectedConfigFile = nil + configPublishFailure = nil + } catch { + let detail = error.localizedDescription + guard configWriteOwed || config.agentCutoffs != Config.agentDefaultCutoffs else { + if rejectedConfigFile != nil { Log.info("config.json is missing and the recovery agent's defaults are the app's cutoffs; sessions can start") } + rejectedConfigFile = nil + if configPublishFailure != detail { + Log.error("could not write the settings in use to the missing config.json (\(detail)); the recovery agent enforces the cutoffs recorded for a session in state.json, else its defaults, and both are the app's \(config.agentCutoffs.description)") + } + configPublishFailure = detail + return + } + let why = "Insomnia could not write the settings it uses to config.json. The recovery agent reads its end floor and thermal rules from that file, so Insomnia runs no session until the file is written. Free some disk space or make \(paths.appSupport.path) writable." + if rejectedConfigFile != why { Log.error("\(why) (\(detail))") } + rejectedConfigFile = why + } + } + + /// Settings' way to change the settings. A change to the end floor or + /// the thermal rule (`Config.agentCutoffs`) is written to config.json + /// first and takes effect only once that write succeeds: the agent + /// reads the file, so a cutoff only the app knew would let the agent + /// keep a session the app ends, or end one the app keeps. While a + /// session exists (in this process or as session.json) the new cutoffs + /// are recorded for it in the journal before that, under the recovery + /// lock taken without waiting (`recordSessionCutoffs`), since the agent + /// enforces the record while config.json cannot be used; the lock is + /// held until config.json is written. A busy lock, an unreadable + /// journal or a write that fails leaves both on the old cutoffs (a + /// journal record already written is put back), says so in + /// `configSaveError`, and returns false. When the record cannot be put + /// back either, the journal holds cutoffs config.json does not, so the + /// session ends: on disk before the lock is released + /// (`endSessionOnDisk`), then in process. Any other change takes effect + /// at once and is written behind it; a write that fails is logged, and + /// the next save writes it. + @discardableResult + func updateConfig(_ change: (inout Config) -> Void) -> Bool { + var c = config + change(&c) + guard c != config else { return true } + let cutoffsChange = c.agentCutoffs != config.agentCutoffs + var recorded: (lock: RecoveryLockHandle, before: AgentCutoffs?)? + defer { recorded?.lock.release() } + if cutoffsChange, session != nil || store.sessionEntryExists() { + do { + recorded = try recordSessionCutoffs(c.agentCutoffs) + } catch { + let line = "Could not record the change for the session in state.json (\(error.localizedDescription)). The recovery agent enforces the end floor and thermal rules recorded there when it cannot use config.json, so both stay at \(config.agentCutoffs.description)." + Log.error("settings: \(c.agentCutoffs.description) not applied: \(line)") + configSaveError = line + return false + } + } + do { + try store.saveConfig(c) + } catch { + let detail = error.localizedDescription + guard cutoffsChange else { + Log.error("could not save config: \(detail)") + config = c + return false + } + if let recorded { + beforeRecordedCutoffsPutBack?() + do { + try journal { $0.sessionCutoffs = recorded.before } + } catch { + // The journal records cutoffs config.json does not + // carry, and the agent enforces that record whenever it + // cannot use config.json. The session ends on disk here, + // under the lock the record was written under, so no + // agent run or relaunch reads it as live; the undo + // follows in its own transaction. + let putBack = error.localizedDescription + Log.error("settings: could not put the session's recorded \(recorded.before?.description ?? "absence of cutoffs") back in state.json (\(putBack)); ending the session") + let retained = RecoveryLock.$held.withValue(recorded.lock) { endSessionOnDisk() } + endTicket += 1 + pendingEnd = .cutoffsNotRecorded + Task { @MainActor [weak self] in _ = await self?.end(reason: .cutoffsNotRecorded) } + let line = "Could not save the change to config.json (\(detail)), or put back the end floor and thermal rules recorded for the session in state.json (\(putBack)). The recovery agent reads them from those files, so Insomnia ended the session" + (retained.map { ": \($0)" } ?? ".") + " The settings stay at \(config.agentCutoffs.description)." + Log.error("settings: \(c.agentCutoffs.description) not applied: \(line)") + configSaveError = line + return false + } + } + let line = "Could not save the change to config.json (\(detail)). The recovery agent reads the end floor and thermal rules from that file, so both stay at \(config.agentCutoffs.description)." + Log.error("settings: \(c.agentCutoffs.description) not applied: \(line)") + configSaveError = line + return false + } + config = c + configSaveError = nil + if cutoffsChange { services?.reevaluateFloors() } + return true + } + + private struct SessionCutoffsNotRecorded: LocalizedError { + let errorDescription: String? + } + + /// Takes the recovery lock without waiting and records `cutoffs` for + /// the session in the journal read under it. Returns the lock, for the + /// caller to release once config.json is written, and the cutoffs the + /// journal recorded before. Throws, with nothing changed and the lock + /// released, when the lock is busy (an agent run or a transaction), the + /// journal cannot be read, or the write fails. + private func recordSessionCutoffs(_ cutoffs: AgentCutoffs) throws -> (lock: RecoveryLockHandle, before: AgentCutoffs?) { + guard let lock = try recoveryLock.tryAcquire() else { + throw SessionCutoffsNotRecorded(errorDescription: "the recovery lock is busy; try again in a moment") + } + do { + try loadJournal() + let before = state.sessionCutoffs + try journal { $0.sessionCutoffs = cutoffs } + return (lock, before) + } catch { + lock.release() + throw error + } + } + + /// A session still running while config.json is rejected in place, or + /// while its replacement is owed, ends through the normal path, as any + /// other cutoff does. + private func endIfConfigFileRejected() async { + guard session != nil, let why = rejectedConfigFile else { return } + Log.error("ending the session: \(why)") + endTicket += 1 + _ = await performEnd(reason: .settingsFileRejected) + } + + /// Records the cutoffs in use for the running session in the journal + /// (`RuntimeState.sessionCutoffs`) when it holds others or none: after + /// `checkConfigFile` took a hand edit, or after a write that failed. + /// backstop.sh enforces that record while config.json is missing, + /// cannot be read or is rejected, so a hung app's session keeps the + /// cutoffs the app enforced last. A session whose cutoffs cannot be + /// recorded ends through the normal path, as one whose config.json is + /// rejected does. + private func publishSessionCutoffs() async { + guard session != nil, state.sessionCutoffs != config.agentCutoffs else { return } + do { + try journal { $0.sessionCutoffs = config.agentCutoffs } + } catch { + Log.error("ending the session: could not record its \(config.agentCutoffs.description) in state.json (\(error.localizedDescription)); the recovery agent enforces the cutoffs recorded there while config.json cannot be used") + endTicket += 1 + _ = await performEnd(reason: .cutoffsNotRecorded) + } + } + + private static func rejectedConfigMessage(_ path: String, detail: String, moveError: String) -> String { + "config.json could not be read (\(detail)) or moved aside (\(moveError)). The recovery agent reads its end floor and thermal rules from that file itself, so Insomnia runs no session until the file is fixed. Make \(path) writable or delete it." + } + + /// The 1 Hz tick's look for a session the agent ended while the lid was + /// open and nothing else transacted: a cheap look first (session.json + /// gone, or recorded as ended in ended-session.json, the journal, a + /// record aside on disk, the recovery lock file or the log; a record + /// aside lists the folder, and the logs are read again only once they + /// or session.json changed), then the decision + /// and the end under the lock (`adoptAgentEnd`). Internal so tests can + /// run one tick at a time. + func noticeAgentEnd() async { + guard session != nil, !checkingAgentEnd, now >= agentEndRetryAt, + !FileManager.default.fileExists(atPath: paths.sessionFile.path) || store.sessionEndIsRecorded() + || ((try? store.loadState()).map { store.sessionEndIsJournaled(in: $0) } ?? false) + || store.sessionEndRecordAside() != nil || store.sessionEndRecordedInLock() != nil + || logRecordsSessionEnd() + else { return } + checkingAgentEnd = true + defer { checkingAgentEnd = false } + let result = await exclusive("agent end") {} + if case .failure = result { + agentEndRetryAt = now.addingTimeInterval(recoveryRetryDelay) + } + } + + /// The tick's look at the logs for the end of the session: read again + /// only when session.json or a log changed since the last read + /// (`Store.logEndRecordFingerprint`). + private func logRecordsSessionEnd() -> Bool { + let fingerprint = store.logEndRecordFingerprint() + if let last = lastLogEndRecordRead, last.fingerprint == fingerprint { return last.found } + let found = store.sessionEndRecordedInLog() != nil + lastLogEndRecordRead = (fingerprint, found) + return found + } + + /// The 1 Hz tick's look at config.json while a session runs: a file that + /// is missing, does not decode, or carries another end floor or thermal + /// rule than the app (a hand edit, a deletion), or a journal that + /// records other cutoffs for the session than the app's, goes through a + /// transaction (`checkConfigFile`, `publishSessionCutoffs`), so the app + /// and the agent agree within a second, not at the next Start, lid + /// event or extend. A file still in that state after the transaction (a + /// write that failed, a busy lock) is looked at again after + /// `recoveryRetryDelay`. Internal so tests can run one tick at a time. + func noticeConfigFileChange() async { + guard session != nil, !checkingConfigFile, now >= configCheckRetryAt, configFileDiffers() else { return } + checkingConfigFile = true + defer { checkingConfigFile = false } + _ = await exclusive("settings check") {} + if configFileDiffers() { + configCheckRetryAt = now.addingTimeInterval(recoveryRetryDelay) + } + } + + /// config.json is missing, does not decode, or has other cutoffs than + /// the settings in use, or state.json records others for the session + /// (missing counts; one that cannot be read is left to the transaction + /// that reads it, which refuses). + private func configFileDiffers() -> Bool { + guard let onDisk = try? store.loadConfig() else { return true } + if onDisk.agentCutoffs != config.agentCutoffs { return true } + let journal: RuntimeState? + do { + journal = try store.loadState() + } catch { + return false + } + return journal?.sessionCutoffs != config.agentCutoffs + } + /// Disk is the source of truth. Missing means clean; anything that does /// not decode throws and is left exactly as it is. private func loadJournal() throws { @@ -1191,6 +1688,11 @@ final class SessionManager { Log.info("start abandoned: an end was requested first") return } + if let why = rejectedConfigFile { + fail("start refused, nothing changed: \(why)") + notifier.post(title: Self.configFileTitle, body: "Insomnia did not start a session. \(why)") + return + } let now = clock() let new = SessionMath.newSession(now: now, duration: duration, maxDuration: config.maxDuration) // What was on disk before this attempt, read under the lock. A @@ -1208,14 +1710,64 @@ final class SessionManager { fail("start refused, nothing changed: session.json could not be read (\(error.localizedDescription)). Remove it or move it out of \(paths.appSupport.path), then start again") return } + // A rollback puts back the session.json this start replaces byte for + // byte, since every record of its end matches exact bytes, and with + // it the recovery lock file, which may hold such a record + // (`Store.recordSessionEndInLock`). Both must be read whole. With no + // session.json the lock file ends nothing: its bytes are put back + // when they can be read, and a file that cannot be read is emptied + // and stays empty. + var sessionBytesBefore: Data? + var lockBefore = store.lockContents() + if sessionBefore != nil { + guard let bytes = try? store.readData(from: paths.sessionFile) else { + fail("start refused, nothing changed: session.json could not be read again") + return + } + guard let lock = lockBefore else { + fail("start refused, nothing changed: \(paths.recoveryLock.path) could not be read whole, and it may record the end of the session.json in place") + return + } + sessionBytesBefore = bytes + lockBefore = lock + } + #if DEBUG + onStartStepForTesting?(.lockFileRead) + #endif do { + // Before the new session.json exists, the lock file is left + // holding nothing that could count as its end: a stale record's + // first bytes are also the first bytes of the new session's + // record. What records the end of the session.json being + // replaced stays, whole, until that file is gone + // (`Store.settleLockForStart`). + guard store.settleLockForStart(replacing: sessionBytesBefore) else { + throw StoreError.lockRecordNotCleared(file: paths.recoveryLock.path) + } try store.saveSession(new) keptSessionFile = nil - try journal { $0.sleepDisabledByUs = true } + #if DEBUG + onStartStepForTesting?(.sessionWritten) + #endif + // The cutoffs the agent enforces for this session when it + // cannot use config.json (`RuntimeState.sessionCutoffs`), in + // the journal before anything runs for the session. A recorded + // end of an earlier session.json goes in the same write, only + // after the file it ends is replaced: a crash before it then + // leaves that earlier file ended, and a new file with the same + // bytes would read as ended, the safe side. + try journal { + $0.endedSession = nil + $0.sleepDisabledByUs = true + $0.sessionCutoffs = config.agentCutoffs + } + #if DEBUG + onStartStepForTesting?(.journalWritten) + #endif } catch { fail("could not write session: \(error.localizedDescription)") - rollBackStart(journal: journalBefore, session: sessionBefore) + rollBackStart(journal: journalBefore, session: sessionBytesBefore, lock: lockBefore) return } @@ -1224,15 +1776,24 @@ final class SessionManager { do { try await backstop.arm() } catch { - rollBackStart(journal: journalBefore, session: sessionBefore) + rollBackStart(journal: journalBefore, session: sessionBytesBefore, lock: lockBefore) fail("could not arm backstop: \(error.localizedDescription)") return } guard endTicket == ticket else { - rollBackStart(journal: journalBefore, session: sessionBefore) + rollBackStart(journal: journalBefore, session: sessionBytesBefore, lock: lockBefore) Log.info("start abandoned before disabling sleep: end requested meanwhile") return } + // The record of the replaced session.json's end goes last, once + // nothing left can roll this start back: it ends no other file, so + // until now a rollback only had to cut the file back to the bytes + // it held, never write them again. + guard store.clearLockEndRecord() else { + rollBackStart(journal: journalBefore, session: sessionBytesBefore, lock: lockBefore) + fail("could not write session: \(StoreError.lockRecordNotCleared(file: paths.recoveryLock.path).localizedDescription)") + return + } do { try await sleepGuard.setSleepDisabled(true) @@ -1308,7 +1869,8 @@ final class SessionManager { /// Requesting an end invalidates every start, extend or Low Power change /// still queued or in flight. If the recovery lock cannot be taken the /// end changes nothing and is retried in process. If the journal cannot - /// be read the end changes nothing and waits for a person. + /// be read the end changes nothing and is retried too, until a person + /// repairs the file or the agent ends the session. @discardableResult func end(reason: EndReason) async -> EndOutcome { endTicket += 1 @@ -1316,31 +1878,30 @@ final class SessionManager { retryTimer?.invalidate() retryTimer = nil let outcome: EndOutcome - switch await exclusive("end", owes: .end(reason), { await self.performEnd(reason: reason) }) { + switch await exclusive("end", owes: .end(reason), syncSession: false, { await self.performEnd(reason: reason) }) { case let .success(o): outcome = o case .failure(.lockBusy): outcome = .locked case .failure(.journalUnreadable): outcome = .journalUnreadable case let .failure(.commandRunning(pid)): outcome = .privilegedCommandRunning(pid: pid) } switch outcome { - case .restored, .incomplete(agentArmed: true): - pendingEnd = nil + case .restored, .incomplete, .sessionRetained: + // performEnd settled the pending end or scheduled its retry. + break case .locked: notifier.post( title: Self.notEndedTitle, body: "The recovery lock is held by another process, so nothing was changed. The session is still active; Insomnia retries in \(Int(recoveryRetryDelay)) s." ) scheduleEndRetry(reason) - case .incomplete(agentArmed: false), .sessionRetained: - scheduleEndRetry(reason) case .journalUnreadable: - // No timer: a broken file does not heal by itself. The end stays - // pending, so new starts are refused and quit is deferred, until - // the next end request finds a readable journal. - pendingEnd = reason - quitRequested = false + // The end stays pending, so new starts are refused and quit is + // deferred. The retry ends the session once a person repairs the + // file; if the agent ends it first, the tick adopts that end. The + // notification goes out once per error, so a retry only logs. + scheduleEndRetry(reason) case .privilegedCommandRunning: - // No timer either: the task holding the lock for the command + // No timer: the task holding the lock for the command // retries the end the moment it exits. Nothing is recorded // here: the refusal or the stopped end recorded `pendingEnd` // before that task could run, and it may already have run and @@ -1370,17 +1931,7 @@ final class SessionManager { countdownText = "" // Why session.json is still in place when a relaunch could act on // it; the end is then retried and quit refused. - var retainedBecause: String? - if let kept = keptSessionFile { - retainedBecause = retryMovingAsideKeptSessionFile(kept) - } else { - do { - try store.deleteSession() - } catch { - retainedBecause = "session.json could not be removed (\(error.localizedDescription)); a relaunch would hold sleep again for it." - fail("could not remove session.json: \(error.localizedDescription)") - } - } + let retainedBecause = endSessionOnDisk() let stuck = await restoreAll() services?.stop() @@ -1480,13 +2031,53 @@ final class SessionManager { // Reconcile and a failed start reach here without `end()`; the // retry is scheduled here so they are covered too (rescheduling // from `end()` is harmless). - if !agentCanFinish { scheduleEndRetry(reason) } + if agentCanFinish { settlePendingEnd() } else { scheduleEndRetry(reason) } return .incomplete(agentArmed: agentCanFinish) } notifier.post(title: Self.endTitle(reason, had: had), body: endBody(reason, waiting: waiting)) + settlePendingEnd() return .restored } + /// The end decided on disk, under the lock the caller holds, before + /// anything is undone: session.json removed (moved aside when it was + /// kept unread), or, when it cannot be removed, recorded as ended in + /// ended-session.json, else the journal, else a record aside, else the + /// recovery lock file, else a line in insomnia.log, so it does not read + /// as a live session to the next launch or to backstop.sh. Returns why + /// session.json is still in place when a relaunch could act on it, or + /// nil. + private func endSessionOnDisk() -> String? { + if let kept = keptSessionFile { + return retryMovingAsideKeptSessionFile(kept) + } + do { + try store.deleteSession() + dropJournaledSessionEnd() + return nil + } catch { + let recordedIn: String? = store.recordSessionEnd() ? "ended-session.json" + : journalSessionEnd() ? "state.json" + : store.recordSessionEndAside()?.lastPathComponent + ?? (store.recordSessionEndInLock() ? "the recovery lock file \(paths.recoveryLock.lastPathComponent)" + : store.recordSessionEndInLog() ? "the log file \(paths.logFile.lastPathComponent)" : nil) + let relaunch = recordedIn != nil + ? "its end is recorded, so a relaunch will not resume it" + : "a relaunch does not resume it while the file cannot be replaced, but once it and state.json take writes again, one while sleep is still disabled could hold sleep again for it" + fail("could not remove session.json: \(error.localizedDescription)" + (recordedIn.map { "; its end is recorded in \($0)" } ?? "; its end could not be recorded either")) + return "session.json could not be removed (\(error.localizedDescription)); \(relaunch)." + } + } + + /// An end that finished, or left the rest to an armed agent, resolves + /// any end still pending, whichever path ran it: `end()`, the adoption + /// of an end the agent made, or reconcile. Its retry timer is obsolete. + private func settlePendingEnd() { + pendingEnd = nil + retryTimer?.invalidate() + retryTimer = nil + } + /// What an end checks before it reports a restore. A brightness kept /// after a refused restore is left out of `isDirty`, since no build /// whose guard refuses it can restore it, and so is one waiting for a @@ -2496,6 +3087,10 @@ final class SessionManager { /// makes active first (`earlierCommandCheckOwed`). func reconcile() async { announceLidCloseUpdate() + if let configNotice { + self.configNotice = nil + notifier.post(title: Self.configFileTitle, body: configNotice) + } reconcileRetry?.cancel() reconcileRetry = nil await reconcile(ticket: endTicket, isRetry: false) @@ -2610,11 +3205,54 @@ final class SessionManager { onDisk = nil } - if let s = onDisk, !s.isExpired(at: now) { + // A session ended earlier whose session.json could not be removed + // (ended-session.json, the journal's endedSession, a record aside, + // the recovery lock file or a line in insomnia.log or + // insomnia.log.1 holds its bytes, or the lock file cannot be read + // whole in three tries) is over, deadline or not, whatever pmset + // reads now. A record in the log is named before a lock file that + // cannot be read, which only may hold the end. Other content in the + // lock file ends nothing (`Store.LockEndRecord.foreign`). + let endRecordedIn: String? = onDisk == nil ? nil + : store.sessionEndIsRecorded() ? "ended-session.json" + : store.sessionEndIsJournaled(in: state) ? "state.json" + : store.sessionEndRecordAside()?.lastPathComponent ?? store.sessionEndRecordedInLockOrLog()?.place + let endedEarlier = endRecordedIn != nil + + // A valid session is not resumed while config.json is rejected in + // place: the agent would enforce the file's cutoffs, not the app's. + let resumable = onDisk.map { !$0.isExpired(at: now) && !endedEarlier } ?? false + + if let s = onDisk, resumable, rejectedConfigFile == nil { // Step 2: valid session. Arm first, then journal, then hold // sleep. Any failure ends the session rather than holding sleep // with nothing guaranteed to release it. session = s + // A disable the journal records must still be in effect. Only an + // end undoes it, and nothing outside this transaction can run one + // now (the recovery lock), so a bit that reads 0 was set back + // while no Insomnia ran: by an agent end that could neither + // remove session.json nor record the end anywhere (it restores + // sleep and keeps the entry), by hand, or never set by a start + // that died before its pmset. Holding sleep again would revive a + // session that was ended, so it ends here, and the end is + // recorded wherever it can be now. A read that fails cannot + // confirm the hold, so it ends the session too. + if state.sleepDisabledByUs { + let held: Bool + do { + held = try await sleepGuard.isSleepDisabled() + } catch { + fail("could not read SleepDisabled for the session on disk: \(error.localizedDescription); ending it") + _ = await performEnd(reason: .recoveryUnavailable) + return + } + guard held else { + Log.error("reconcile: session.json holds a session whose sleep hold was undone while Insomnia was not running: sleepDisabledByUs is journaled but SleepDisabled reads 0 (a recovery agent end that could record nothing, a hand-run pmset, or a start that died before disabling sleep); ending it, not resuming") + _ = await performEnd(reason: .backstop) + return + } + } do { try await backstop.arm() } catch { @@ -2622,14 +3260,55 @@ final class SessionManager { _ = await performEnd(reason: .recoveryUnavailable) return } - if !state.sleepDisabledByUs { - do { - try journal { $0.sleepDisabledByUs = true } - } catch { - fail("could not journal sleep guard: \(error.localizedDescription); ending session") - _ = await performEnd(reason: .recoveryUnavailable) - return + // Written even when sleepDisabledByUs is already set: a session + // resumes only from a journal this process can write. An agent + // run that ended the session but could neither remove + // session.json nor record the end anywhere leaves just that flag + // behind; when its restore failed as well, the bit still reads 1 + // above, and the write that fails here keeps the session it + // ended from resuming while state.json stays unwritable. + // The cutoffs the agent enforces for the session while + // config.json cannot be used go in with it + // (`RuntimeState.sessionCutoffs`). + do { + try journal { + $0.sleepDisabledByUs = true + $0.sessionCutoffs = config.agentCutoffs } + } catch { + fail("could not journal sleep guard: \(error.localizedDescription); ending session") + _ = await performEnd(reason: .recoveryUnavailable) + return + } + // An end, by the agent or by an earlier app, removes + // session.json, and records the end when it cannot: in + // ended-session.json, the journal, a new file beside them or in + // the log folder, or else the recovery lock file, which exists + // already. When none of those took a write (the lock file not a + // regular file this user owns, or refusing the write too, as on + // a full disk), nothing on disk says the session is over, and + // the bit pmset reports cannot say it either: it is global, and + // another process or a failed restore can leave it at 1. Such an + // end left a session.json it could not remove, so sleep is held + // again only for a session.json that can be replaced now (the + // same bytes, written and read back). One that cannot ends here + // instead, and the end is recorded wherever it can be now. + // Nothing tells such an end from a crash once session.json and + // the journal take writes again. + guard store.rewriteSessionFile() else { + Log.error("reconcile: session.json holds a session valid until \(iso(s.endsAt)), but it cannot be replaced, so an end of it may have gone unrecorded (a recovery agent end that could neither remove it nor record its end anywhere); ending it, not resuming") + _ = await performEnd(reason: .backstop) + return + } + // Whatever the recovery lock file holds ends nothing here (it + // would have ended the session above), so it is emptied before + // the session goes on: an end of it written to that file later + // then starts from an empty file. One that cannot be emptied + // stays; the end's writer keeps only the bytes the file shares + // with the record, so it never leaves the record's first bytes + // over these (`RecoveryLockHandle.replaceContents`). + if !store.clearLockEndRecord() { + Log.error("reconcile: could not empty \(paths.recoveryLock.path), which holds bytes that end no session; resuming, and an end recorded there later replaces them") } do { try await sleepGuard.setSleepDisabled(true) @@ -2665,16 +3344,23 @@ final class SessionManager { return } - // Step 1: missing or expired -> full end. A restore stopped at a - // sudo pmset that did not stop on SIGTERM ends the reconcile too: - // step 3 would run a second `disablesleep 0` beside the live one. - // The lock goes to the command and the end is retried when it exits. - // A session.json kept in place is renamed by an end, which is - // retried until it moves, so saved output volumes alone then call - // for one too, as on any other dirty journal. + // Step 1: missing, expired, ended earlier, or not to be resumed -> + // full end. A restore stopped at a sudo pmset that did not stop on + // SIGTERM ends the reconcile too: step 3 would run a second + // `disablesleep 0` beside the live one. The lock goes to the command + // and the end is retried when it exits. A session.json kept in place + // is renamed by an end, which is retried until it moves, so saved + // output volumes alone then call for one too, as on any other dirty + // journal. let savedOutputs = Set(state.savedAudioOutputs.map(\.deviceUID)) let owesEnd = keptSessionFile != nil ? state.isDirty : state.isDirty(leavingOutAudioOf: savedOutputs) - if onDisk != nil { + if endedEarlier { + Log.info("reconcile: session.json holds a session already ended (recorded in \(endRecordedIn ?? "ended-session.json")); restoring, not resuming") + if case .privilegedCommandRunning = await performEnd(reason: .backstop) { return } + } else if let s = onDisk, resumable, let why = rejectedConfigFile { + Log.error("reconcile: session valid until \(iso(s.endsAt)) not resumed: \(why)") + if case .privilegedCommandRunning = await performEnd(reason: .settingsFileRejected) { return } + } else if onDisk != nil { Log.info("reconcile: session expired, restoring") if case .privilegedCommandRunning = await performEnd(reason: .timer) { return } } else if owesEnd { @@ -2894,24 +3580,43 @@ final class SessionManager { // MARK: Private - /// Undo the writes made at the top of `start` by restoring the journal - /// and session file exactly as they were read under this transaction's - /// lock. Nothing has touched the machine at this point. - private func rollBackStart(journal before: RuntimeState, session previous: Session?) { + /// Undo the writes made at the top of `start` by restoring the journal, + /// the session file and the recovery lock file exactly as they were + /// read under this transaction's lock. Lock bytes that count as the end + /// of the earlier session.json go back before that file: until the + /// start's last step the lock file holds that record whole, so putting + /// back its first bytes only cuts the file + /// (`RecoveryLockHandle.replaceContents`). Other bytes ended nothing; + /// the start emptied them before it wrote its session.json, and they go + /// back only once the earlier file is back or the new one is gone, so + /// none of them is read against the new session. `lock` is nil when + /// they could not be read; the file then stays empty. Nothing has + /// touched the machine at this point. + private func rollBackStart(journal before: RuntimeState, session previous: Data?, lock lockBefore: Data?) { do { try persistState(before) } catch { fail("could not restore the journal after a failed start: \(error.localizedDescription)") } + var lockAfterSession = lockBefore + if let previous, let lockBefore, Store.lockContents(lockBefore, endSessionWithBytes: previous) { + lockAfterSession = nil + if !store.restoreLockContents(lockBefore) { + fail("could not restore \(paths.recoveryLock.path) after a failed start; it may hold more or less of the record of an earlier session's end than it did") + } + } do { if let previous { - try store.saveSession(previous) + try store.restoreSessionFile(previous) } else { try store.deleteSession() } } catch { fail("could not restore session.json after a failed start: \(error.localizedDescription)") } + if let lockAfterSession, !store.restoreLockContents(lockAfterSession) { + fail("could not restore \(paths.recoveryLock.path) after a failed start; it held bytes that ended no session, and it is left as it is") + } } /// In-process timers only; the launchd agent is armed by callers before @@ -2947,7 +3652,12 @@ final class SessionManager { guard session != nil else { return } let first = SessionMath.nextSecondBoundary(after: clock()) let timer = Timer(fire: first, interval: 1, repeats: true) { [weak self] _ in - Task { @MainActor in self?.refreshCountdown() } + Task { @MainActor in + guard let self else { return } + self.refreshCountdown() + await self.noticeAgentEnd() + await self.noticeConfigFileChange() + } } timer.tolerance = 0.1 RunLoop.main.add(timer, forMode: .common) @@ -2961,6 +3671,41 @@ final class SessionManager { countdownTimer = nil } + /// Records the end of the session in session.json in the journal + /// (`RuntimeState.endedSession`), for an end that could neither remove + /// that file nor write ended-session.json, such as an unrelated record + /// there that cannot be replaced. True when the journal on disk now + /// records it. + private func journalSessionEnd() -> Bool { + guard let marker = store.sessionEndMarker() else { return false } + if state.endedSession == marker { return true } + do { + try journal { $0.endedSession = marker } + return true + } catch { + Log.error("could not record the end of session.json in state.json either: \(error.localizedDescription)") + return false + } + } + + /// Removes a journaled end once its session.json is gone. It would end + /// nothing, since it matches only that file's bytes, but a later file + /// with the same bytes would read as ended. The cutoffs recorded for + /// that session go too; they apply to no other. A write that fails + /// leaves them for the next one; a start removes the end before it + /// writes session.json and records its own cutoffs. + private func dropJournaledSessionEnd() { + guard state.endedSession != nil || state.sessionCutoffs != nil else { return } + do { + try journal { + $0.endedSession = nil + $0.sessionCutoffs = nil + } + } catch { + Log.error("could not remove the recorded session end and cutoffs from state.json: \(error.localizedDescription)") + } + } + private func persistState(_ s: RuntimeState) throws { try store.saveState(s) state = s @@ -3129,6 +3874,7 @@ final class SessionManager { static let journalTitle = "Recovery journal unreadable" static let commandRunningTitle = "Power command still running" static let sessionFileTitle = "Session file unreadable" + static let configFileTitle = "Settings file unreadable" static let foreignSleepTitle = "Sleep is disabled by something else" static let foreignSleepCommand = "sudo pmset -a disablesleep 0" static let foreignSleepLine = "Sleep is disabled by something other than Insomnia; to re-enable it: \(foreignSleepCommand)" @@ -3183,8 +3929,11 @@ final class SessionManager { case .backstop: outputsWaiting ? "A previous session left changes behind. Sleep is back to normal." : "A previous session left changes behind; everything has been undone." + case .agentCutoff: "The recovery agent ended the session while Insomnia could not (see insomnia.log for its reason). Sleep is back to normal." case .recoveryUnavailable: "Insomnia could not arm its recovery agent for the session found on disk, so it ended the session. Sleep is back to normal." case .startFailed: "Insomnia could not disable sleep, so no session was started. Sleep is back to normal." + case .settingsFileRejected: "\(rejectedConfigFile ?? "config.json could not be read or moved aside.") Sleep is back to normal." + case .cutoffsNotRecorded: "Insomnia could not record the session's end floor and thermal rules in state.json, which the recovery agent reads when it cannot use config.json, so it ended the session. Sleep is back to normal." } } } diff --git a/Sources/Insomnia/InsomniaApp.swift b/Sources/Insomnia/InsomniaApp.swift index 77a796d5..a8e374b2 100644 --- a/Sources/Insomnia/InsomniaApp.swift +++ b/Sources/Insomnia/InsomniaApp.swift @@ -22,6 +22,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let status: any StatusSource let secrets: any HotspotSecretStore let locationPermission: LocationPermission + /// Held from launch to exit (see AppAliveLock): backstop.sh ends a valid + /// session once it can take this lock, because the app is then gone. A + /// launch that cannot take it quits (LaunchGate). + let aliveLock: AppAliveLock let loginItem = LoginItem() private var statusItem: StatusItemController? private var settingsWindow: SettingsWindow? @@ -30,6 +34,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { override init() { let manager = SessionManager.live() self.manager = manager + aliveLock = AppAliveLock(url: manager.paths.appAliveFile) secrets = KeychainHotspotSecretStore(keychain: KeychainStore()) { manager.config.hotspotSSID } @@ -57,6 +62,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if LidSimulationBuild.isCompiledIn { Log.info(LidSimulationBuild.marker) } + let gate = LaunchGate(aliveLock: aliveLock, notifier: manager.notifier) + Task { + if await !gate.open(manager: manager, start: { self.start() }) { + NSApp.terminate(nil) + } + } + } + + /// The rest of a launch, run only once this process holds the alive + /// lock, before reconcile. + private func start() { // The login item is tied to the bundle's signature, which install.sh // renews on every run: register again if the flag is on, macOS no // longer reports the item and the install changed; follow the user @@ -84,7 +100,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusItem = StatusItemController(manager: manager, status: status) { [weak settings] in settings?.show() } - Task { await manager.reconcile() } } /// Quitting always ends the session (spec 1). Terminate is deferred until @@ -98,7 +113,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// outlive a quit (the command holds it through its own descriptor), /// but the end the command holds up would not: the app is what retries /// it, and confirms an undo the command finishes, the moment it exits. + /// + /// A copy without the alive lock never reconciled or started anything, + /// and an end there would restore the journal of the copy that holds + /// the lock, ending that copy's session. It quits at once. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard aliveLock.isHeld else { return .terminateNow } guard !terminating else { return .terminateCancel } terminating = true Task { diff --git a/Sources/Insomnia/Model/Config.swift b/Sources/Insomnia/Model/Config.swift index 2b3bf03a..5c90ccb5 100644 --- a/Sources/Insomnia/Model/Config.swift +++ b/Sources/Insomnia/Model/Config.swift @@ -8,8 +8,12 @@ struct Config: Codable, Equatable, Sendable { /// Preset durations in seconds, shown as chips. var presets: [TimeInterval] = Config.defaultPresets var defaultPreset: TimeInterval = 4 * 3600 - /// Hard ceiling on a session, including extensions. 30 days. - var maxDuration: TimeInterval = 30 * 24 * 3600 + /// Hard ceiling on a session, including extensions. 24 hours by default: + /// the backstop ends a session within a minute of the app going away, but + /// the deadline is still the last line, and one typo should not hold a + /// closed laptop awake for days. Raise it in config.json for longer + /// sessions; the Days pill accepts up to 30 days. + var maxDuration: TimeInterval = 24 * 3600 // Lid-close actions /// Bundle ids to SIGSTOP while the lid is closed. @@ -81,6 +85,12 @@ struct Config: Codable, Equatable, Sendable { /// first launch with the flag on registers once and records it. var launchAtLoginInstall: String? + /// Which defaults the file was written under; 2 is the 24-hour ceiling. + /// A file without the key comes from an older build, and only such a + /// file has its stock values migrated on read. + var configVersion: Int = Config.currentVersion + static let currentVersion = 2 + // One-time updates /// Whether this config has had the lid-close update /// (`applyLidCloseDefaults`). True for a config this build creates; @@ -101,9 +111,17 @@ struct Config: Codable, Equatable, Sendable { 8 * 3600, 12 * 3600, 24 * 3600, - 3 * 24 * 3600, ] + /// What builds before the 24-hour ceiling wrote into config.json as + /// their defaults. Settings saves the whole struct, so an ordinary + /// install has these as explicit values; in a file without + /// `configVersion` the decoder reads exactly these as the current + /// defaults and keeps any other value, which a person chose by hand, + /// unless it no longer fits under the new ceiling. + static let legacyMaxDuration: TimeInterval = 30 * 24 * 3600 + static let legacyPresets: [TimeInterval] = defaultPresets + [3 * 24 * 3600] + /// Default freeze list: chat apps that burn battery in the background. static let defaultFreezeList: [String] = [ "com.tinyspeck.slackmacgap", // Slack @@ -143,7 +161,25 @@ struct Config: Codable, Equatable, Sendable { let d = Config() presets = try c.decodeIfPresent([TimeInterval].self, forKey: .presets) ?? d.presets defaultPreset = try c.decodeIfPresent(TimeInterval.self, forKey: .defaultPreset) ?? d.defaultPreset - maxDuration = try c.decodeIfPresent(TimeInterval.self, forKey: .maxDuration) ?? d.maxDuration + let savedMax = try c.decodeIfPresent(TimeInterval.self, forKey: .maxDuration) + maxDuration = savedMax ?? d.maxDuration + // In a current file every value was written by this build or by + // hand, a 30-day ceiling included; only an older file is migrated. + // The key alone marks a current file, as in Store.configHasVersion: + // its value is never decoded, so a hand-edited "2" cannot fail the + // whole file. + if !c.contains(.configVersion) { + if presets == Config.legacyPresets { presets = d.presets } + if maxDuration == Config.legacyMaxDuration { maxDuration = d.maxDuration } + // A ceiling the user never set is now 24 hours, not the 30 days + // the presets and default were picked under. Presets above it go + // (Settings refuses to add them), and a default above it moves + // to the largest preset left, since bare Enter would refuse it. + if savedMax == nil || savedMax == Config.legacyMaxDuration { + presets.removeAll { $0 > maxDuration } + if defaultPreset > maxDuration { defaultPreset = presets.max() ?? d.defaultPreset } + } + } freezeList = try c.decodeIfPresent([String].self, forKey: .freezeList) ?? d.freezeList freezeAllApps = try c.decodeIfPresent(Bool.self, forKey: .freezeAllApps) ?? d.freezeAllApps dockerRule = try c.decodeIfPresent(Bool.self, forKey: .dockerRule) ?? d.dockerRule @@ -267,3 +303,63 @@ extension Config { return "battery floors corrected: lowPowerFloor \(before.0) -> \(lowPowerFloor), endFloor \(before.1) -> \(endFloor)" } } + +// MARK: Cutoffs the recovery agent enforces + +/// The two settings backstop.sh enforces on its own while a session runs, +/// the end floor and the thermal rule, as `AgentCutoffsCommand` prints them +/// from config.json, or from the journal's record of them +/// (`RuntimeState.sessionCutoffs`) when config.json cannot be used. +struct AgentCutoffs: Equatable, Sendable { + /// 0...`Config.maxEndFloor`; 0 is off. + let endFloor: Int + let thermalRules: Bool + + init(endFloor: Int, thermalRules: Bool) { + self.endFloor = endFloor + self.thermalRules = thermalRules + } + + /// The form the journal records them in, and the one + /// `AgentCutoffsCommand` prints after "cutoffs ": the end floor in + /// decimal, a space, and true or false. `"30 false"`. + var journalValue: String { "\(endFloor) \(thermalRules)" } + + /// Reads `journalValue` back: nil for anything else, such as a floor + /// outside 0...`Config.maxEndFloor`, a leading zero, a sign, other + /// spacing, or another word for the rule. No other text means the + /// same cutoffs. + init?(journalValue text: String) { + let parts = text.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count == 2, let floorText = parts.first, let rule = parts.last, + !floorText.isEmpty, floorText.count <= 2, + floorText.utf8.allSatisfy({ (0x30...0x39).contains($0) }), + floorText == "0" || !floorText.hasPrefix("0"), + let floor = Int(floorText), floor <= Config.maxEndFloor + else { return nil } + switch rule { + case "true": thermalRules = true + case "false": thermalRules = false + default: return nil + } + endFloor = floor + } + + var description: String { + "end floor \(endFloor == 0 ? "off" : "\(endFloor)%"), thermal rules \(thermalRules ? "on" : "off")" + } +} + +extension Config { + /// The cutoffs the agent enforces for this config: the end floor + /// clamped as `normalizeFloors` clamps it (above `maxEndFloor` is + /// `maxEndFloor`, 0 or below is off). + var agentCutoffs: AgentCutoffs { + AgentCutoffs(endFloor: min(max(endFloor, 0), Config.maxEndFloor), thermalRules: thermalRules) + } + + /// What the agent enforces while config.json is missing, or holds bytes + /// the app's decoder rejects: the app's defaults (read_cutoffs in + /// backstop.sh). + static let agentDefaultCutoffs = AgentCutoffs(endFloor: 10, thermalRules: true) +} diff --git a/Sources/Insomnia/Model/RuntimeState.swift b/Sources/Insomnia/Model/RuntimeState.swift index 0fde46ae..97434c1c 100644 --- a/Sources/Insomnia/Model/RuntimeState.swift +++ b/Sources/Insomnia/Model/RuntimeState.swift @@ -202,6 +202,34 @@ struct RuntimeState: Codable, Equatable, Sendable { /// each with the value to put back. Not a lid action: restored at /// session end, at reconcile, or by the backstop with `defaults`. var appNapOverrides: [AppNapOverride] = [] + /// The end of the session in session.json, recorded for an end that + /// could neither remove that file nor write ended-session.json (an + /// unrelated record there that cannot be replaced): the file's exact + /// bytes in base64. While it matches the file, that session is over, + /// as with ended-session.json (`Store.sessionEndIsJournaled`). Written + /// by this app or by backstop.sh, before anything is undone. A record, + /// not something to undo: it counts neither as dirty nor as an undo + /// entry, and only this app removes it, before it writes a new + /// session.json and after it removes one. + var endedSession: String? = nil + /// The end floor and thermal rule the app enforces for the session in + /// session.json, recorded before the session starts or resumes and + /// before a change to them takes effect (`SessionManager`'s start, + /// reconcile, `publishSessionCutoffs` and `updateConfig`). backstop.sh + /// enforces config.json's cutoffs, and these while config.json is + /// missing, cannot be read, or holds bytes the app rejects, so a hung + /// app's session keeps the floor it had. Written as + /// `AgentCutoffs.journalValue` ("30 false"). A record, not something to + /// undo: it counts neither as dirty nor as an undo entry, backstop.sh + /// keeps it as it is, and the app clears it when it removes + /// session.json. A value the app does not write decodes as nil here + /// (`decodeSessionCutoffs`), so the app records its own over it. The + /// agent's reader answers `foreign` for it, and backstop.sh counts that + /// as no record, as the app reads it: the app's defaults while + /// config.json is missing, cannot be read or is rejected, and the + /// strictest cutoffs only while config.json is there and read neither by + /// the app's binary nor by backstop.sh. + var sessionCutoffs: AgentCutoffs? = nil /// Bare pids of every journaled freeze, for display and de-duplication. var frozenPids: [Int32] { frozenProcesses.map(\.pid) } @@ -274,7 +302,8 @@ struct RuntimeState: Codable, Equatable, Sendable { /// The undo entries alone: the state without /// `displayRestoredUnderLowPower`, a write owed after the mode rather /// than something to undo, `keptDisplayUnderLowPower` or - /// `keptDisplayReadLit`. Two states with equal entries owe the same + /// `keptDisplayReadLit`, and without `endedSession` and + /// `sessionCutoffs`, records. Two states with equal entries owe the same /// undos. var undoEntries: RuntimeState { var entries = self @@ -282,6 +311,8 @@ struct RuntimeState: Codable, Equatable, Sendable { entries.keptDisplayUnderLowPower = nil entries.keptDisplayUnderLowPowerBoot = nil entries.keptDisplayReadLit = nil + entries.endedSession = nil + entries.sessionCutoffs = nil return entries } @@ -328,7 +359,7 @@ struct RuntimeState: Codable, Equatable, Sendable { case savedDisplayBrightness, savedKeyboardBrightness, displayRestoredUnderLowPower case displayRestoreRefused, keyboardRestoreRefused case keptDisplayUnderLowPower, keptDisplayUnderLowPowerBoot, keptDisplayReadLit - case appNapOverrides + case appNapOverrides, endedSession, sessionCutoffs } // Tolerate missing keys so a state.json written by an older build, or by @@ -359,6 +390,27 @@ struct RuntimeState: Codable, Equatable, Sendable { keptDisplayUnderLowPowerBoot = try c.decodeIfPresent(String.self, forKey: .keptDisplayUnderLowPowerBoot) keptDisplayReadLit = try c.decodeIfPresent(Float.self, forKey: .keptDisplayReadLit) appNapOverrides = try c.decodeIfPresent([AppNapOverride].self, forKey: .appNapOverrides) ?? [] + endedSession = try c.decodeIfPresent(String.self, forKey: .endedSession) + sessionCutoffs = try? Self.decodeSessionCutoffs(from: decoder) + } + + private enum SessionCutoffsKey: String, CodingKey { + case sessionCutoffs + } + + /// The journal's `sessionCutoffs` as the agent's reader takes it + /// (`AgentCutoffsCommand`'s `--agent-session-cutoffs`): nil when the key + /// is absent or null; throws when the journal is not a JSON object or + /// the value is not a string `AgentCutoffs(journalValue:)` reads. + /// Duplicate and escaped keys come out as this decoder reads them. + /// `init(from:)` takes a throw as nil. + static func decodeSessionCutoffs(from decoder: Decoder) throws -> AgentCutoffs? { + let c = try decoder.container(keyedBy: SessionCutoffsKey.self) + guard let text = try c.decodeIfPresent(String.self, forKey: .sessionCutoffs) else { return nil } + guard let cutoffs = AgentCutoffs(journalValue: text) else { + throw DecodingError.dataCorruptedError(forKey: .sessionCutoffs, in: c, debugDescription: "\(text.debugDescription) is not an end floor from 0 to \(Config.maxEndFloor) and true or false, such as \"30 false\"") + } + return cutoffs } /// `frozenPids` is read for migration only and never written again, so @@ -383,5 +435,19 @@ struct RuntimeState: Codable, Equatable, Sendable { try c.encodeIfPresent(keptDisplayUnderLowPowerBoot, forKey: .keptDisplayUnderLowPowerBoot) try c.encodeIfPresent(keptDisplayReadLit, forKey: .keptDisplayReadLit) try c.encode(appNapOverrides, forKey: .appNapOverrides) + // Only when set, so a journal without a record keeps the bytes + // earlier builds wrote. + try c.encodeIfPresent(endedSession, forKey: .endedSession) + try c.encodeIfPresent(sessionCutoffs?.journalValue, forKey: .sessionCutoffs) + } +} + +/// state.json's `sessionCutoffs` alone, read strictly +/// (`RuntimeState.decodeSessionCutoffs`), for `AgentCutoffsCommand`. +struct JournaledSessionCutoffs: Decodable { + let cutoffs: AgentCutoffs? + + init(from decoder: Decoder) throws { + cutoffs = try RuntimeState.decodeSessionCutoffs(from: decoder) } } diff --git a/Sources/Insomnia/Store/LogEndRecord.swift b/Sources/Insomnia/Store/LogEndRecord.swift new file mode 100644 index 00000000..9af86c2f --- /dev/null +++ b/Sources/Insomnia/Store/LogEndRecord.swift @@ -0,0 +1,225 @@ +import Darwin +import Foundation + +/// The last place the end of a session is recorded, after the recovery lock +/// file: one line appended to insomnia.log, a file that already exists, so +/// the record needs no new file and no write in place. The line is this +/// tag, a space, the number of bytes in session.json, a space, and those +/// bytes in base64, then a newline: +/// +/// insomnia-ended-session-v1 92 eyJlbmRzQXQiOiIyMDI3LTAxLTE1VDA4OjMwOjAwWiIs... +/// +/// A record ends exactly the session.json whose bytes it holds, and only +/// as a whole line: a reader builds the line from session.json's current +/// bytes and looks for a line equal to it, in insomnia.log and in +/// insomnia.log.1. A write cut short, a line another write broke into, a +/// different length or other bytes match nothing. A line is ended by a +/// newline or by the end of the file, as grep(1) reads it, so a record +/// whose newline alone is missing still counts: it holds every byte. Every +/// writer of the log (`OwnerOnly.appendToLog`, `append` here, backstop.sh's +/// log and record_end_in_log, the LaunchAgent's own line) holds flock(2) on +/// the file from its look at the last byte to the end of its write +/// (`OwnerOnly.lockLog`), and puts a newline first when the file ends in a +/// line cut short, so such a record keeps its line, a record written after +/// a line cut short starts its own, and no other line, nor the rest of one +/// a short write left to do, lands between the two. A session.json with +/// the same bytes as one that ended earlier, which takes the same start +/// and end times to the second, would read as ended too. +/// +/// It is written only under the recovery lock (`RecoveryLockHandle.locks`), +/// to insomnia.log while that is a regular file (lstat, so not a symlink) +/// this user owns, through a descriptor checked to be on that same file +/// (device and inode), in one write(2), and counts only once it reads back +/// whole from the same file. backstop.sh writes and reads the same line +/// (record_end_in_log, end_recorded_in_log), with the same tag, the same +/// longest session.json and the same largest log searched. +/// +/// The log is rotated by the app alone, and only under the recovery lock +/// (`OwnerOnly.LogRotation`), so no rotation runs while a record is being +/// written and read back. A rotation renames insomnia.log over +/// insomnia.log.1, which discards the old `.1`; before that it copies a +/// record still in `.1` into the file it renames (`keepRecords`), so any +/// number of rotations keep the record of a session whose session.json is +/// still there. A record is dropped only once session.json is gone or holds +/// other bytes, as that session's file was then removed. When `.1` cannot +/// be read, or session.json cannot be read and `.1` holds any record, the +/// rotation waits and is tried again with the next line. +enum LogEndRecord { + static let tag = "insomnia-ended-session-v1" + /// The longest session.json a record copies; the app writes a few + /// hundred bytes. + static let maxSessionBytes = 64 * 1024 + /// The longest record line, without its newline. + static let maxLineBytes = tag.utf8.count + 1 + 5 + 1 + (maxSessionBytes + 2) / 3 * 4 + /// The most of one log file read when looking for a record. A larger + /// file cannot be searched, which counts as a read that failed. + static let maxScanBytes: Int = 64 << 20 + + /// The record line for `session`'s bytes, without its newline, or nil + /// for an empty or oversized file, which is never recorded. + static func line(for session: Data) -> Data? { + guard !session.isEmpty, session.count <= maxSessionBytes else { return nil } + return Data("\(tag) \(session.count) \(session.base64EncodedString())".utf8) + } + + enum Scan: Equatable { + /// Missing, or not a regular file (lstat) this user owns, which is + /// never read as a log. + case notUsable + /// It could not be read whole (the text says why). + case unreadable(String) + /// Read whole: whether a line equals the one searched for, and + /// whether any line starts with the tag. + case read(found: Bool, anyRecord: Bool) + } + + /// Reads the log at `url` for a line equal to `line`. + static func scan(_ url: URL, for line: Data?) -> Scan { + var st = stat() + guard lstat(url.path, &st) == 0, st.st_mode & S_IFMT == S_IFREG, st.st_uid == getuid() else { return .notUsable } + guard st.st_size <= maxScanBytes else { + return .unreadable("it holds \(st.st_size) bytes, more than is searched for a record") + } + // O_NONBLOCK and O_NOFOLLOW: what took the place of the file checked + // above is never waited on or followed. + let fd = open(url.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { return .unreadable("it could not be read (\(String(cString: strerror(errno))))") } + defer { close(fd) } + var opened = stat() + guard fstat(fd, &opened) == 0, opened.st_dev == st.st_dev, opened.st_ino == st.st_ino else { + return .unreadable("it changed while it was opened") + } + return scan(fd: fd, for: line) + } + + private static func scan(fd: Int32, for wanted: Data?) -> Scan { + let prefix = Array((tag + " ").utf8) + let want = wanted.map { Array($0) } + var found = false + var anyRecord = false + var current: [UInt8] = [] + // Past maxLineBytes a line can hold no record; the rest of it is + // skipped up to the next newline. + var skipping = false + func endOfLine() { + if !skipping, current.starts(with: prefix) { + anyRecord = true + if let want, current == want { found = true } + } + current.removeAll(keepingCapacity: true) + skipping = false + } + func add(_ bytes: UnsafeRawBufferPointer) { + guard !skipping else { return } + if current.count + bytes.count > maxLineBytes { + current.removeAll(keepingCapacity: true) + skipping = true + } else { + current.append(contentsOf: bytes) + } + } + var buffer = [UInt8](repeating: 0, count: 1 << 16) + var total = 0 + while true { + let n = buffer.withUnsafeMutableBytes { Darwin.read(fd, $0.baseAddress!, $0.count) } + if n < 0 { + if errno == EINTR { continue } + return .unreadable("it could not be read (\(String(cString: strerror(errno))))") + } + if n == 0 { break } + total += n + guard total <= maxScanBytes else { return .unreadable("it grew past \(maxScanBytes) bytes while it was read") } + buffer.withUnsafeBytes { raw in + var start = 0 + while start < n, let hit = memchr(raw.baseAddress! + start, 0x0A, n - start) { + let at = raw.baseAddress!.distance(to: UnsafeRawPointer(hit)) + add(UnsafeRawBufferPointer(rebasing: raw[start.. Bool { + var named = stat() + guard lstat(url.path, &named) == 0, named.st_mode & S_IFMT == S_IFREG, named.st_uid == getuid() else { return false } + // Opened for reading too, for its last byte; write-only when that + // is all this user may do, and then the newline always goes first. + var fd = open(url.path, O_RDWR | O_APPEND | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + if fd < 0, errno == EACCES { fd = open(url.path, O_WRONLY | O_APPEND | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) } + guard fd >= 0 else { return false } + // Closing the descriptor lets its lock go. + defer { close(fd) } + guard (try? OwnerOnly.lockLog(fd, path: url.path, timeout: lockTimeout)) != nil else { return false } + var held = stat() + guard fstat(fd, &held) == 0, held.st_mode & S_IFMT == S_IFREG, held.st_uid == getuid(), + lstat(url.path, &named) == 0, named.st_mode & S_IFMT == S_IFREG, + held.st_dev == named.st_dev, held.st_ino == named.st_ino else { return false } + return appendHeld(line, to: fd, at: url) + } + + /// The write and the read-back, on a descriptor already locked and + /// checked to be on the log at `url` (O_APPEND). + private static func appendHeld(_ line: Data, to fd: Int32, at url: URL) -> Bool { + let whole = (OwnerOnly.endsMidLine(fd) ? Data([0x0A]) : Data()) + line + Data([0x0A]) + let written = whole.withUnsafeBytes { Darwin.write(fd, $0.baseAddress!, $0.count) } + guard written == whole.count else { return false } + var held = stat() + var named = stat() + guard fstat(fd, &held) == 0, lstat(url.path, &named) == 0, + held.st_dev == named.st_dev, held.st_ino == named.st_ino else { return false } + return scan(url, for: line) == .read(found: true, anyRecord: true) + } + + /// For a rotation of the log at `url`, held open (O_APPEND) and locked + /// (`OwnerOnly.lockLog`) on `fd`, about to be renamed over `url`.1: + /// copies into it the record in `.1` + /// of the session in `session` (session.json), when `.1` holds one, so + /// the rename discards no record still in force. True when the rename + /// may go ahead: session.json is gone, `.1` holds no record of its + /// bytes, or the copy reads back whole. False keeps the log as it is: + /// `.1` cannot be read, the copy fails, or session.json cannot be read + /// and `.1` holds any record. + static func keepRecords(in fd: Int32, log url: URL, session: URL) -> Bool { + let previous = OwnerOnly.rotated(url) + var st = stat() + var bytes: Data? + if stat(session.path, &st) != 0 { + // Gone, or a symlink to nothing, which no reader takes for a + // session: every record is spent. + if errno == ENOENT { return true } + } else if st.st_mode & S_IFMT == S_IFREG { + // Larger than any record copies: no record can be of it. + if st.st_size > maxSessionBytes { return true } + bytes = try? Data(contentsOf: session) + } + guard let bytes else { + switch scan(previous, for: nil) { + case .notUsable, .read(found: _, anyRecord: false): return true + default: return false + } + } + guard let line = line(for: bytes) else { return true } + switch scan(previous, for: line) { + case .notUsable, .read(found: false, anyRecord: _): return true + case .unreadable: return false + case .read(found: true, anyRecord: _): return appendHeld(line, to: fd, at: url) + } + } +} diff --git a/Sources/Insomnia/Store/OwnerOnly.swift b/Sources/Insomnia/Store/OwnerOnly.swift index b3cc5ef2..77423d91 100644 --- a/Sources/Insomnia/Store/OwnerOnly.swift +++ b/Sources/Insomnia/Store/OwnerOnly.swift @@ -28,7 +28,18 @@ import Foundation /// rename would move the link itself; that is reported once, and the cap /// does not hold for it. The user set up the link, so the file it points to /// is theirs to trim. The backstop appends with `>>` and never rotates, so -/// it simply creates the fresh file. +/// it simply creates the fresh file. insomnia.log can hold the record of a +/// session's end (`LogEndRecord`), so the app rotates it only under the +/// recovery lock, and copies such a record forward before the rename +/// discards the old `.1` (`LogRotation`). +/// +/// That same flock(2) is the one lock every writer of insomnia.log holds +/// from its look at the file's last byte to the end of its write +/// (`lockLog`): `appendToLog`, `LogEndRecord.append`, backstop.sh's log and +/// record_end_in_log, and the LaunchAgent's own line. So no line joins +/// another, a write cut short and then continued included, and no rename +/// runs while a line is in flight. A writer takes no other lock while it +/// holds this one: the recovery lock and `Log`'s lock come first. enum OwnerOnly { static let fileMode: mode_t = 0o600 static let directoryMode: mode_t = 0o700 @@ -36,6 +47,22 @@ enum OwnerOnly { /// of history, and `.1` doubles it. Not enforced for a symlinked log; /// see above. static let maxLogBytes: UInt64 = 1 << 20 + /// How long a line waits for another writer's lock on the log + /// (`lockLog`). backstop.sh and the LaunchAgent hold it for one line, + /// or for one end record and its read-back. + static let logLockTimeout: TimeInterval = 2 + /// How many times one append opens the log: once, and again each time + /// the file it locked is no longer the one the path names. + static let maxLogOpens = 4 + + #if DEBUG + /// Tests stand in for write(2) in `appendToLog`: given the descriptor, + /// the bytes still to write and their count, it returns what write(2) + /// would. So a test can cut each write short, pause between two writes + /// of one line, or write nothing. Debug builds only; nothing reads it + /// otherwise. + nonisolated(unsafe) static var logWriteForTesting: ((Int32, UnsafeRawPointer, Int) -> Int)? + #endif /// Creates `dir` and any missing parents, then makes `dir` itself 0700. /// Parents are left alone: only Insomnia's own directory is tightened. @@ -86,6 +113,7 @@ enum OwnerOnly { private static let reported = PathSet() private static let symlinkedLogs = PathSet() + private static let recordsNotKept = PathSet() private final class PathSet: @unchecked Sendable { private let lock = NSLock() @@ -102,48 +130,127 @@ enum OwnerOnly { /// Appends `text` to the log at `url`: the directory is created 0700, /// the file 0600 (an existing file is tightened), and a file already /// past `maxBytes` is rotated first so the line lands in a new file. - /// The line is written even when a chmod or the rotation fails; the - /// first such failure is then thrown so the caller can report it. - /// `beforeRotating` runs once the file held is found past the cap and - /// before its lock is taken; a throw from it ends the append there, with - /// nothing rotated or written. Tests use it to rotate from outside first. + /// The file opened is locked (`lockLog`, at most `lockTimeout`) before + /// its last byte is read, and stays locked until the whole of `text` + /// is written, every write a short write leaves to do included. Once + /// locked, a file the path no longer names (another process rotated it + /// meanwhile) is let go and the path opened again, up to `maxLogOpens` + /// opens in all; after that the line goes to the file held, which the + /// lock still keeps whole. A file whose last line was cut short + /// (`endsMidLine`) gets a newline first, so `text` never joins it: that + /// line may be the record of a session's end without its newline, which + /// counts as the end only while nothing follows it on its line + /// (`LogEndRecord`). A lock not taken in time throws `.busy`, and one + /// that fails throws `.lock`, with nothing written. Otherwise the line is + /// written even when a chmod or the rotation fails; the first such + /// failure is then thrown so the caller can report it. `rotation` says + /// whether the file may be rotated now (`LogRotation`). `beforeRotating` + /// runs once, the first time a file opened is found past the cap, before + /// its lock is taken; a throw from it ends the append there, with + /// nothing rotated or written. Tests use it to rotate from outside + /// first. static func appendToLog( _ text: String, at url: URL, maxBytes: UInt64 = maxLogBytes, + rotation: LogRotation = .free, + lockTimeout: TimeInterval = logLockTimeout, beforeRotating: () throws -> Void = {} ) throws { var problems: [OwnerOnlyError] = [] if let problem = try createDirectory(url.deletingLastPathComponent()) { problems.append(problem) } - var fd = try openForAppend(url) + // Closing the descriptor lets its lock go. + var fd: Int32 = -1 defer { if fd >= 0 { close(fd) } } - // Before any rotation, so a legacy 0644 log is 0600 by the time it - // becomes `.1`. - if let problem = tighten(fd: fd, path: url.path) { problems.append(problem) } - if size(of: fd) > maxBytes { - try beforeRotating() - switch rotateHeld(fd, at: url, maxBytes: maxBytes) { - case let .keep(problem): - // The held file is still the log: keep writing to it. - if let problem { problems.append(problem) } - case .reopen: - // Rotated, by this process or another: the path is a fresh file. - close(fd) - fd = -1 - fd = try openForAppend(url) - if let problem = tighten(fd: fd, path: url.path) { problems.append(problem) } + var opens = 0 + var askedBeforeRotating = false + while true { + if fd >= 0 { close(fd) } + fd = -1 + fd = try openForAppend(url) + opens += 1 + // Before any rotation, so a legacy 0644 log is 0600 by the time it + // becomes `.1`. + if let problem = tighten(fd: fd, path: url.path) { problems.append(problem) } + if !askedBeforeRotating, size(of: fd) > maxBytes, !rotation.isDeferred { + askedBeforeRotating = true + try beforeRotating() + } + try lockLog(fd, path: url.path, timeout: lockTimeout) + if opens < maxLogOpens, !names(url, fd) { continue } + if size(of: fd) > maxBytes, !rotation.isDeferred { + switch rotateHeld(fd, at: url, maxBytes: maxBytes, keep: rotation.keep) { + case let .keep(problem): + // The held file is still the log: keep writing to it. + if let problem { problems.append(problem) } + case .reopen: + // Rotated, by this process or another: the path is a fresh file. + if opens < maxLogOpens { continue } + } } + break } - try writeAll(Data(text.utf8), to: fd, path: url.path) + var write: (Int32, UnsafeRawPointer, Int) -> Int = { Darwin.write($0, $1, $2) } + #if DEBUG + if let injected = logWriteForTesting { write = injected } + #endif + try writeAll((endsMidLine(fd) ? Data([0x0A]) : Data()) + Data(text.utf8), to: fd, path: url.path, using: write) if let first = problems.first { throw first } } + /// Takes flock(2) on the log open on `fd`, trying again every 2 ms for + /// at most `timeout`: the lock every writer of the log holds from its + /// look at the last byte to the end of its write (see the type's + /// documentation). Closing `fd` lets it go; the caller holds no other + /// descriptor on that open file. Throws `.busy` once `timeout` has + /// passed and `.lock` when flock(2) fails another way. + static func lockLog(_ fd: Int32, path: String, timeout: TimeInterval) throws { + let deadline = ContinuousClock.now + .milliseconds(Int(max(0, timeout) * 1000)) + while flock(fd, LOCK_EX | LOCK_NB) != 0 { + let err = errno + if err == EINTR { continue } + guard err == EWOULDBLOCK else { throw OwnerOnlyError.lock(path: path, errno: err) } + guard ContinuousClock.now < deadline else { throw OwnerOnlyError.busy(path: path, seconds: timeout) } + usleep(2000) + } + } + + /// Whether `url` names the file open on `fd`, following a symlink as + /// open(2) did: the same device and inode. + private static func names(_ url: URL, _ fd: Int32) -> Bool { + var held = stat() + var named = stat() + return fstat(fd, &held) == 0 && stat(url.path, &named) == 0 + && held.st_dev == named.st_dev && held.st_ino == named.st_ino + } + + /// Opened for reading too, so `endsMidLine` can read the last byte; a + /// file this user may only write to is still appended to. private static func openForAppend(_ url: URL) throws -> Int32 { - let fd = open(url.path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, fileMode) + var fd = open(url.path, O_RDWR | O_APPEND | O_CREAT | O_CLOEXEC, fileMode) + if fd < 0, errno == EACCES { fd = open(url.path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, fileMode) } guard fd >= 0 else { throw OwnerOnlyError.open(path: url.path, errno: errno) } return fd } + /// Whether the file open on `fd` ends in a line cut short: it is not + /// empty and its last byte, read through `fd`, is not a newline, or + /// that byte cannot be read. An appender then writes a newline before + /// its own line, so a line cut short, by a write that failed partway or + /// by a record whose newline alone is missing, stays a line of its own. + /// backstop.sh's ends_mid_line reads the same way. + static func endsMidLine(_ fd: Int32) -> Bool { + var st = stat() + guard fstat(fd, &st) == 0 else { return true } + guard st.st_size > 0 else { return false } + var last: UInt8 = 0 + while true { + let n = pread(fd, &last, 1, st.st_size - 1) + if n < 0, errno == EINTR { continue } + return n != 1 || last != 0x0A + } + } + private static func size(of fd: Int32) -> UInt64 { var st = stat() guard fstat(fd, &st) == 0 else { return 0 } @@ -153,6 +260,33 @@ enum OwnerOnly { /// `.1` next to the log: insomnia.log.1, handoffs.log.1. static func rotated(_ url: URL) -> URL { url.appendingPathExtension("1") } + /// When a log past the cap is rotated. + enum LogRotation { + /// At once: handoffs.log. + case free + /// Not now: the line goes to the file as it is, past the cap, and + /// the next append that may rotate does. insomnia.log while this + /// process does not hold the recovery lock, since backstop.sh writes + /// and reads back an end record there under that lock alone. + case deferred + /// Once `keep`, given the descriptor on the file about to be + /// renamed, has copied into it what the rename would discard + /// (`LogEndRecord.keepRecords`). False keeps the file as it is, past + /// the cap, and the next append tries again. insomnia.log under the + /// recovery lock. + case keeping((Int32) -> Bool) + + var isDeferred: Bool { + if case .deferred = self { return true } + return false + } + + var keep: ((Int32) -> Bool)? { + if case let .keeping(keep) = self { return keep } + return nil + } + } + /// What `rotateHeld` leaves the appender to do. private enum Rotation { /// The path names a fresh file, whoever rotated: open it. @@ -162,17 +296,18 @@ enum OwnerOnly { case keep(OwnerOnlyError?) } - /// rename(2) the held file over the previous `.1`, under flock(2) on it. - /// Two processes can both find the log oversized; the one that locks - /// first renames, and the other then sees that the path no longer names - /// the file it holds and leaves the fresh log alone. The path is read - /// with lstat(2): a symlinked log is not renamed, because rename moves - /// the link, not its target, and the next open would start a plain file - /// in its place. A rename keeps the inode and its mode; the backstop may - /// still have a line in flight to it, which then lands in `.1`. - private static func rotateHeld(_ fd: Int32, at url: URL, maxBytes: UInt64) -> Rotation { - guard flock(fd, LOCK_EX) == 0 else { return .keep(.rotate(path: url.path, errno: errno)) } - defer { flock(fd, LOCK_UN) } + /// rename(2) the held file over the previous `.1`. The caller holds + /// flock(2) on it (`lockLog`). Two processes can both find the log + /// oversized; the one that locks first renames, and the other then sees + /// that the path no longer names the file it holds and leaves the fresh + /// log alone. The path is read with lstat(2): a symlinked log is not + /// renamed, because rename moves the link, not its target, and the next + /// open would start a plain file in its place. A rename keeps the inode + /// and its mode. A writer that opened the file before the rename waits + /// for the lock, then finds the path names another file and opens it. + /// `keep`, when given, runs under the same flock once the file held is + /// found to be the log and past the cap; false keeps it, reported once. + private static func rotateHeld(_ fd: Int32, at url: URL, maxBytes: UInt64, keep: ((Int32) -> Bool)?) -> Rotation { var held = stat() var named = stat() guard fstat(fd, &held) == 0 else { return .keep(.rotate(path: url.path, errno: errno)) } @@ -186,6 +321,9 @@ enum OwnerOnly { } guard held.st_ino == named.st_ino, held.st_dev == named.st_dev else { return .reopen } guard UInt64(max(0, held.st_size)) > maxBytes else { return .keep(nil) } + if let keep, !keep(fd) { + return .keep(recordsNotKept.insert(url.path) ? .endRecordNotKept(path: url.path) : nil) + } guard rename(url.path, rotated(url).path) == 0 else { return .keep(.rotate(path: url.path, errno: errno)) } return .reopen } @@ -199,16 +337,25 @@ enum OwnerOnly { try writeAll(data, to: fd, path: url.path) } - private static func writeAll(_ data: Data, to fd: Int32, path: String) throws { + /// Writes all of `data`, a write cut short followed by one for the + /// rest, through `write`, write(2) unless a test stands in for it. + private static func writeAll( + _ data: Data, + to fd: Int32, + path: String, + using write: (Int32, UnsafeRawPointer, Int) -> Int = { Darwin.write($0, $1, $2) } + ) throws { try data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in guard let base = buf.baseAddress else { return } var offset = 0 while offset < buf.count { - let n = Darwin.write(fd, base + offset, buf.count - offset) + let n = write(fd, base + offset, buf.count - offset) if n < 0 { if errno == EINTR { continue } throw OwnerOnlyError.write(path: path, errno: errno) } + // A write that moved nothing would be tried forever. + guard n > 0 else { throw OwnerOnlyError.write(path: path, errno: EIO) } offset += n } } @@ -221,12 +368,15 @@ enum OwnerOnlyError: Error, LocalizedError { case chmod(path: String, errno: Int32) case rotate(path: String, errno: Int32) case symlinkNotRotated(path: String) + case endRecordNotKept(path: String) + case lock(path: String, errno: Int32) + case busy(path: String, seconds: TimeInterval) var path: String { switch self { - case let .open(path, _), let .write(path, _), let .chmod(path, _), let .rotate(path, _): + case let .open(path, _), let .write(path, _), let .chmod(path, _), let .rotate(path, _), let .lock(path, _): return path - case let .symlinkNotRotated(path): + case let .symlinkNotRotated(path), let .endRecordNotKept(path), let .busy(path, _): return path } } @@ -238,6 +388,9 @@ enum OwnerOnlyError: Error, LocalizedError { case let .chmod(path, errno): return "could not make \(path) owner-only: \(String(cString: strerror(errno)))" case let .rotate(path, errno): return "could not rotate \(path) to \(path).1: \(String(cString: strerror(errno)))" case let .symlinkNotRotated(path): return "not rotating \(path): it is a symlink, so its target can grow past the cap" + case let .endRecordNotKept(path): return "not rotating \(path) yet: \(path).1 may hold the record of the end of the session in session.json, and it could not be read or copied forward; the next line tries again" + case let .lock(path, errno): return "could not lock \(path) to append to it: \(String(cString: strerror(errno)))" + case let .busy(path, seconds): return "\(path) stayed locked by another writer for \(seconds.formatted()) s; nothing was written to it" } } } diff --git a/Sources/Insomnia/Store/Paths.swift b/Sources/Insomnia/Store/Paths.swift index 5341d453..340cfdb6 100644 --- a/Sources/Insomnia/Store/Paths.swift +++ b/Sources/Insomnia/Store/Paths.swift @@ -64,10 +64,36 @@ struct Paths: Sendable, Equatable { } var sessionFile: URL { appSupport.appendingPathComponent("session.json") } + /// Written when a session is ended but session.json cannot be removed (an + /// immutable file): a copy of that file's exact bytes. While the two + /// match, the session is over whatever its endsAt says. backstop.sh + /// writes and honours the same file. + var endedSessionFile: URL { appSupport.appendingPathComponent("ended-session.json") } + /// The same record under a fresh name, for when neither + /// ended-session.json nor state.json can be written: this prefix and + /// eight letters or digits, created exclusively (Store, and mktemp in + /// backstop.sh), in one of `endedSessionAsideFolders`. Only a regular + /// file this user owns with exactly that name counts; backstop.sh and + /// uninstall.sh use the same shape. + static let endedSessionAsidePrefix = "ended-session.json." + /// Where records aside go and are looked for, in this order: beside + /// ended-session.json, then the log folder, which is outside + /// Application Support and so can take a new file when that folder + /// does not. The log folder counts only while it is a directory, not a + /// symlink, owned by this user. No other folder is searched. + var endedSessionAsideFolders: [URL] { [appSupport, logs] } + static func isEndedSessionAsideName(_ name: String) -> Bool { + guard name.hasPrefix(endedSessionAsidePrefix) else { return false } + let suffix = name.utf8.dropFirst(endedSessionAsidePrefix.utf8.count) + return suffix.count == 8 && suffix.allSatisfy { (0x30...0x39).contains($0) || (0x41...0x5A).contains($0) || (0x61...0x7A).contains($0) } + } /// Where an unreadable session.json goes: this prefix, a UTC stamp /// (yyyyMMddTHHmmssZ) and, if that name is taken, -1, -2, ... The same /// shape is produced by backstop.sh and removed by `uninstall.sh --purge`. static let unreadableSessionPrefix = "session.json.unreadable-" + /// Where a config.json that does not decode goes at launch, named the + /// same way. Only the app moves it; `uninstall.sh --purge` removes it. + static let unreadableConfigPrefix = "config.json.unreadable-" var stateFile: URL { appSupport.appendingPathComponent("state.json") } var configFile: URL { appSupport.appendingPathComponent("config.json") } /// scripts/backstop.sh as install.sh seals it into a bundle, under @@ -81,6 +107,10 @@ struct Paths: Sendable, Equatable { /// flock(2) file shared with backstop.sh (`lockf -k` on the same path). /// Created once, never unlinked, so both sides lock the same inode. var recoveryLock: URL { appSupport.appendingPathComponent(".recovery.lock") } + /// flock(2) file the app holds for its whole lifetime (`AppAliveLock`). + /// backstop.sh probes it without waiting: acquiring it means no Insomnia + /// process is alive, and a valid session is then ended. Never unlinked. + var appAliveFile: URL { appSupport.appendingPathComponent(".app.alive") } /// The `sudo pmset` left running that holds the recovery lock, written /// while it runs so a relaunch after a crash can name it. Removed when /// it exits, and by the next transaction that takes the lock. diff --git a/Sources/Insomnia/Store/Store.swift b/Sources/Insomnia/Store/Store.swift index b598ec4e..a40de82b 100644 --- a/Sources/Insomnia/Store/Store.swift +++ b/Sources/Insomnia/Store/Store.swift @@ -88,6 +88,13 @@ struct Store: Sendable { /// Returns nil when the file does not exist. Throws on unreadable or /// undecodable content. func read(_ type: T.Type, from url: URL) throws -> T? { + guard let data = try readData(from: url) else { return nil } + return try Store.makeDecoder().decode(T.self, from: data) + } + + /// The file's bytes, or nil when it does not exist. Throws when it + /// cannot be read or is not a regular file. + func readData(from url: URL) throws -> Data? { guard FileManager.default.fileExists(atPath: url.path) else { return nil } // Only a regular file is opened. open(2) on a FIFO with no writer // blocks, and these reads run on the main actor under the recovery @@ -100,13 +107,15 @@ struct Store: Sendable { throw StoreError.notRegularFile(file: url.path) } if let problem = OwnerOnly.tighten(path: url.path) { OwnerOnly.reportOnce(problem) } - let data = try Data(contentsOf: url) - return try Store.makeDecoder().decode(T.self, from: data) + return try Data(contentsOf: url) } /// Atomic write: temp file + rename(2). func write(_ value: T, to url: URL) throws { - let data = try Store.makeEncoder().encode(value) + try write(data: Store.makeEncoder().encode(value), to: url) + } + + private func write(data: Data, to url: URL) throws { let dir = url.deletingLastPathComponent() if let problem = try OwnerOnly.createDirectory(dir) { OwnerOnly.reportOnce(problem) } let tmp = dir.appendingPathComponent(".\(url.lastPathComponent).tmp-\(UUID().uuidString)") @@ -141,7 +150,540 @@ struct Store: Sendable { } } func saveSession(_ s: Session) throws { try write(s, to: paths.sessionFile) } - func deleteSession() throws { try remove(at: paths.sessionFile) } + /// Puts back session.json's exact bytes, as read before a write that is + /// being undone: every record of a session's end matches exact bytes, + /// which encoding the decoded session again need not give. + func restoreSessionFile(_ data: Data) throws { try write(data: data, to: paths.sessionFile) } + /// Removes session.json, then the records of its end, which mean + /// something only while the file they copy is there. A record that + /// cannot be removed is left and logged: it matches no later + /// session.json, but it is still a copy of the session's times. The + /// record in the recovery lock file is emptied in place, through the + /// lock this transaction holds; one that cannot be is left and logged + /// too. It ends nothing while no session.json is there, and the next + /// start (once it has written its session.json) or recovery agent run + /// empties it. + func deleteSession() throws { + try remove(at: paths.sessionFile) + for record in [paths.endedSessionFile] + sessionEndRecordsAside() { + do { + try remove(at: record) + } catch { + Log.error("could not remove \(record.path) (\(error.localizedDescription)); it matches no session.json, so it ends nothing, but it stays until removed by hand") + } + } + if !clearLockEndRecord() { + Log.error("could not empty \(paths.recoveryLock.path) of the record of a session's end; it matches no session.json, so it ends nothing, and the next start or recovery agent run empties it") + } + } + + /// Whether session.json is a session already ended: ended-session.json + /// holds its exact bytes (`recordSessionEnd`, or backstop.sh's + /// record_end). False when either file is missing, unreadable, or not + /// a regular file; the 1 Hz tick calls this, so neither is ever opened + /// unless it is one. + func sessionEndIsRecorded() -> Bool { + guard let recorded = try? readData(from: paths.endedSessionFile), + let current = try? readData(from: paths.sessionFile) else { return false } + return recorded == current + } + + /// For an end that could not remove session.json: copies its bytes to + /// ended-session.json, so this app after a relaunch and backstop.sh treat + /// the session as over. True only when the record now matches the file. + func recordSessionEnd() -> Bool { + if sessionEndIsRecorded() { return true } + guard let current = try? readData(from: paths.sessionFile) else { return false } + do { + try write(data: current, to: paths.endedSessionFile) + } catch { + return false + } + return sessionEndIsRecorded() + } + + /// The records written aside (Paths.endedSessionAsidePrefix) in + /// `Paths.endedSessionAsideFolders`: regular files, not symlinks, owned + /// by this user, with exactly that name shape, folder by folder in + /// name order. The log folder is searched only while it is a directory, + /// not a symlink, owned by this user. Nothing else is ever opened or + /// removed as one. + func sessionEndRecordsAside() -> [URL] { + recordAsideFolders().flatMap { folder -> [URL] in + guard let names = try? FileManager.default.contentsOfDirectory(atPath: folder.path) else { return [] } + return names.filter(Paths.isEndedSessionAsideName).sorted().compactMap { name in + let url = folder.appendingPathComponent(name) + var st = stat() + guard lstat(url.path, &st) == 0, st.st_mode & S_IFMT == S_IFREG, st.st_uid == getuid() else { return nil } + return url + } + } + } + + /// `Paths.endedSessionAsideFolders` as they may be used now: the log + /// folder only while it is a directory (lstat, so not a symlink) this + /// user owns. + private func recordAsideFolders() -> [URL] { + paths.endedSessionAsideFolders.filter { folder in + guard folder != paths.appSupport else { return true } + var st = stat() + return lstat(folder.path, &st) == 0 && st.st_mode & S_IFMT == S_IFDIR && st.st_uid == getuid() + } + } + + /// The record aside that holds session.json's exact bytes, if one does: + /// that session is over, as with ended-session.json. + func sessionEndRecordAside() -> URL? { + let records = sessionEndRecordsAside() + guard !records.isEmpty, let current = try? readData(from: paths.sessionFile) else { return nil } + return records.first { (try? readData(from: $0)) == current } + } + + /// For an end that could not remove session.json or write + /// ended-session.json or the journal: copies its bytes to a new file, + /// created exclusively under a random name, beside them, or in the log + /// folder when their folder takes no new file. A record aside that + /// already matches, in either folder, is used again. Returns the + /// record, only once it reads back identical to the file. + func recordSessionEndAside() -> URL? { + if let existing = sessionEndRecordAside() { return existing } + guard let current = try? readData(from: paths.sessionFile) else { return nil } + _ = try? OwnerOnly.createDirectory(paths.logs) + for folder in recordAsideFolders() { + if let record = createRecordAside(in: folder, contents: current) { return record } + } + return nil + } + + /// One record aside in `folder`, or nil when none could be created + /// there and read back identical to `contents`. + private func createRecordAside(in folder: URL, contents: Data) -> URL? { + let letters = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + for _ in 0..<8 { + let suffix = String((0..<8).map { _ in letters.randomElement()! }) + let url = folder.appendingPathComponent(Paths.endedSessionAsidePrefix + suffix) + do { + try OwnerOnly.createFile(at: url, contents: contents) + } catch OwnerOnlyError.open(_, EEXIST) { + continue + } catch OwnerOnlyError.write { + // Created here, then a write failed: the partial copy goes. + try? FileManager.default.removeItem(at: url) + return nil + } catch { + return nil + } + if (try? readData(from: url)) == contents { return url } + try? FileManager.default.removeItem(at: url) + return nil + } + return nil + } + + /// Writes session.json's bytes back over it, unchanged: a new file + /// renamed into its place, as every write here. True only when that + /// worked and the file then reads back as the same bytes. Reconcile + /// runs it before it resumes a session: a session.json that cannot be + /// replaced is one an end could not remove either, so an end of it may + /// have gone unrecorded. + func rewriteSessionFile() -> Bool { + guard let current = try? readData(from: paths.sessionFile) else { return false } + do { + try write(data: current, to: paths.sessionFile) + } catch { + return false + } + return (try? readData(from: paths.sessionFile)) == current + } + + /// session.json's bytes in base64, the form `RuntimeState.endedSession` + /// records them in (backstop.sh's session_base64 prints the same), or + /// nil when the file is missing, unreadable or not a regular file. + func sessionEndMarker() -> String? { + (try? readData(from: paths.sessionFile))?.base64EncodedString() + } + + /// Whether `journal` records the end of the session in session.json: + /// its endedSession holds that file's bytes. The record written when + /// ended-session.json could not be (SessionManager's + /// `journalSessionEnd`, or backstop.sh's record_end_in_journal). + func sessionEndIsJournaled(in journal: RuntimeState) -> Bool { + guard let recorded = journal.endedSession, let current = sessionEndMarker() else { return false } + return recorded == current + } + + // MARK: The end record in the recovery lock file + + /// The last place the end of a session is recorded, for when + /// ended-session.json, the journal and both folders of records aside + /// refuse it: the recovery lock file, which exists already, so the + /// record needs no new file. It then holds this tag, a space, + /// session.json's bytes in base64 (`sessionEndMarker`), a newline, and + /// nothing else. It is written in place (`RecoveryLockHandle + /// .replaceContents`), so it keeps its inode and stays the lock, and it + /// is never unlinked. backstop.sh reads and writes the same record + /// (read_lock_record, record_end_in_lock) and uninstall.sh empties it. + static let lockEndRecordTag = "ended-session-v1" + /// A larger lock file holds no whole record. + static let lockEndRecordMaxBytes = 1 << 20 + /// How many times the lock file is read before it counts as one that + /// cannot be read (`readLockFile`), and the pause between reads. + /// backstop.sh's read_lock_record reads it as many times. + static let lockReadAttempts = 3 + static let lockReadRetryMicroseconds: useconds_t = 100_000 + + #if DEBUG + /// Tests set a lower limit on the record this app writes in the lock + /// file, 0 for a lock file that refuses it (a stand-in for a full disk), + /// as `PatchedBackstop.refuseLockRecord` does for the agent. Debug + /// builds only; nothing reads it otherwise. + nonisolated(unsafe) static var lockRecordWriteLimitForTesting: Int? + /// Tests set an errno that every read of the lock file through this + /// Store fails with once the file is open, as a disk error would: the + /// app's own lock descriptor and its writes are untouched, so a held + /// lock file can read as unreadable. Debug builds only. + nonisolated(unsafe) static var lockReadErrnoForTesting: Int32? + /// With `lockReadErrnoForTesting`, how many reads fail before the rest + /// go through, as a passing error would; nil for every read. Debug + /// builds only. + nonisolated(unsafe) static var lockReadFailuresForTesting: Int? + #endif + + private static var lockRecordWriteLimit: Int { + #if DEBUG + return lockRecordWriteLimitForTesting ?? lockEndRecordMaxBytes + #else + return lockEndRecordMaxBytes + #endif + } + + /// What the recovery lock file says about the end of a session. + enum LockEndRecord: Equatable { + /// Nothing: the file is empty or missing, or not a regular file + /// (lstat, so not a symlink) this user owns, which is never read or + /// written as a record. + case none + /// A whole record: the end of the session.json whose bytes this + /// base64 holds. + case record(String) + /// Read, and not one whole record (the text says why): a write cut + /// short, other bytes, or more bytes than any record. It ends no + /// session, unless it is the record of the session in session.json + /// cut short as a writer leaves it (`lockHoldsRecordCutShort`), + /// which counts as that session's end. Content that ends nothing is + /// emptied like a stale record (backstop.sh's + /// remove_stale_lock_record, `deleteSession`, a start), and a + /// record written here replaces it. + case foreign(String) + /// The file could not be read whole (the text says why). It may + /// hold a whole record of the session in session.json, so it counts + /// as that session's end, the safe side, until it can be read or + /// session.json is gone. No writer takes it for a record it wrote, + /// and the app's writer leaves it as it is (`recordSessionEndInLock`). + case unreadable(String) + } + + func lockEndRecord() -> LockEndRecord { + switch readLockFile() { + case .notUsable: return .none + case let .tooLarge(size): return .foreign("it holds \(size) bytes, more than an end record") + case let .unreadable(why): return .unreadable(why) + case let .bytes(data): return Self.parseLockEndRecord(data) + } + } + + private enum LockFile { + /// Missing, or not a regular file (lstat) this user owns. + case notUsable + case bytes(Data) + /// Larger than `lockEndRecordMaxBytes`, so not read. + case tooLarge(Int64) + case unreadable(String) + } + + /// The recovery lock file's bytes, read only while it is a regular file + /// (lstat, so not a symlink) this user owns and at most + /// `lockEndRecordMaxBytes` long. A read that fails (an error, or a file + /// that changed while it was read) is tried again, up to + /// `lockReadAttempts` reads in all, `lockReadRetryMicroseconds` apart: + /// an unreadable file may hold the end of the session in session.json + /// and counts as one, so a passing error or a change caught partway + /// must not end a session the file says nothing about. + private func readLockFile() -> LockFile { + var found = readLockFileOnce() + for _ in 1.. LockFile { + let path = paths.recoveryLock.path + var st = stat() + guard lstat(path, &st) == 0, st.st_mode & S_IFMT == S_IFREG, st.st_uid == getuid() else { return .notUsable } + if st.st_size == 0 { return .bytes(Data()) } + guard st.st_size <= Self.lockEndRecordMaxBytes else { return .tooLarge(Int64(st.st_size)) } + // O_NONBLOCK and O_NOFOLLOW: what took the place of the file checked + // above is never waited on or followed. + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { return .unreadable("it could not be read (\(String(cString: strerror(errno))))") } + defer { close(fd) } + var opened = stat() + guard fstat(fd, &opened) == 0, opened.st_dev == st.st_dev, opened.st_ino == st.st_ino else { + return .unreadable("it changed while it was read") + } + #if DEBUG + if let injected = Self.lockReadErrnoForTesting { + let left = Self.lockReadFailuresForTesting + if left.map({ $0 > 0 }) ?? true { + Self.lockReadFailuresForTesting = left.map { $0 - 1 } + return .unreadable("it could not be read (\(String(cString: strerror(injected))))") + } + } + #endif + // One byte more than the size: a file that grew meanwhile is not + // read as the shorter content it held. + var bytes = [UInt8](repeating: 0, count: Int(st.st_size) + 1) + var count = 0 + while count < bytes.count { + let n = bytes.withUnsafeMutableBytes { Darwin.read(fd, $0.baseAddress! + count, $0.count - count) } + if n < 0 { + if errno == EINTR { continue } + return .unreadable("it could not be read (\(String(cString: strerror(errno))))") + } + if n == 0 { break } + count += n + } + guard count == Int(st.st_size) else { return .unreadable("it changed while it was read") } + return .bytes(Data(bytes[.. LockEndRecord { + guard !data.isEmpty else { return .none } + let other = LockEndRecord.foreign("it holds bytes other than one whole end record") + let prefix = Array((lockEndRecordTag + " ").utf8) + let bytes = Array(data) + guard bytes.last == 0x0A, bytes.count > prefix.count + 1, Array(bytes.prefix(prefix.count)) == prefix else { return other } + let encoded = bytes[prefix.count..<(bytes.count - 1)] + let padding = encoded.reversed().prefix { $0 == 0x3D }.count + let digits = encoded.dropLast(padding) + func isDigit(_ b: UInt8) -> Bool { + (0x41...0x5A).contains(b) || (0x61...0x7A).contains(b) || (0x30...0x39).contains(b) || b == 0x2B || b == 0x2F + } + guard padding <= 2, !digits.isEmpty, digits.allSatisfy(isDigit), encoded.count % 4 == 0 else { return other } + return .record(String(decoding: encoded, as: UTF8.self)) + } + + /// Where the recovery lock file records the end of the session in + /// session.json, for the log, or nil when it does not: a record of + /// exactly that file's bytes, that record cut short as a writer leaves + /// it (`lockHoldsRecordCutShort`), or a file that cannot be read whole, + /// which may hold one (`LockEndRecord.unreadable`). Other content read + /// whole that is not a record ends nothing (`LockEndRecord.foreign`). + /// Nothing is recorded for a session.json that is not a regular file. + func sessionEndRecordedInLock() -> String? { + lockEndEvidence()?.place + } + + /// Where the recovery lock file records the end of the session, or + /// else the log (`sessionEndRecordedInLog`), and whether the log is + /// the place. A lock file that cannot be read only may hold the end, + /// while a whole record in the log shows it, so the log is named then. + /// Either way the session counts as ended. + func sessionEndRecordedInLockOrLog() -> (place: String, inLog: Bool)? { + guard let lock = lockEndEvidence() else { return sessionEndRecordedInLog().map { ($0, true) } } + if lock.unreadable, let log = sessionEndRecordedInLog() { return (log, true) } + return (lock.place, false) + } + + private func lockEndEvidence() -> (place: String, unreadable: Bool)? { + var st = stat() + guard stat(paths.sessionFile.path, &st) == 0, st.st_mode & S_IFMT == S_IFREG else { return nil } + switch lockEndRecord() { + case .none: + return nil + case .foreign: + guard let encoded = sessionEndMarker(), lockHoldsRecordCutShort(of: encoded) else { return nil } + return ("\(paths.recoveryLock.lastPathComponent), which holds this session's end record cut short, so it counts as one", false) + case let .unreadable(why): + return ("\(paths.recoveryLock.lastPathComponent), which \(why), so it may hold this session's end and counts as one", true) + case let .record(encoded): + return encoded == sessionEndMarker() ? (paths.recoveryLock.lastPathComponent, false) : nil + } + } + + /// Whether the recovery lock file holds the record of the session.json + /// whose bytes `encoded` holds in base64, cut short as a writer leaves + /// it when it stops partway: the record's first bytes and nothing else + /// (both writers keep the bytes the file shares with the record and + /// append the rest; a write that fails partway), or the whole record + /// followed by bytes the file held before (an older app wrote over the + /// old bytes before it cut the file to length). A writer was recording + /// that end, so it counts as one, the safe side. Other content that is + /// no record ends nothing. backstop.sh's lock_holds_record_cut_short + /// reads the same way. + private func lockHoldsRecordCutShort(of encoded: String) -> Bool { + guard case let .bytes(data) = readLockFile() else { return false } + return Self.holdsRecordCutShort(data, of: encoded) + } + + private static func holdsRecordCutShort(_ data: Data, of encoded: String) -> Bool { + guard !data.isEmpty else { return false } + let whole = Data("\(lockEndRecordTag) \(encoded)\n".utf8) + return data.count < whole.count ? whole.starts(with: data) : data.count > whole.count && data.starts(with: whole) + } + + /// Whether `data`, the recovery lock file's bytes, count as the end of + /// the session.json whose bytes are `session`: a whole record of them, + /// or that record cut short (`holdsRecordCutShort`). + static func lockContents(_ data: Data, endSessionWithBytes session: Data) -> Bool { + let encoded = session.base64EncodedString() + return parseLockEndRecord(data) == .record(encoded) || holdsRecordCutShort(data, of: encoded) + } + + /// For an end that could not remove session.json and could write + /// neither ended-session.json, the journal nor a record aside: the + /// record of its bytes in the recovery lock file, written through + /// `lock`, the handle this transaction holds. A record already there + /// for these bytes is kept. The writer keeps what the file shares with + /// the record and appends the rest (`RecoveryLockHandle + /// .replaceContents`), so a write cut short never leaves less of this + /// end than the file held, and from the first byte it changes the file + /// holds the record's first bytes, which count as this end. A file + /// that cannot be read whole is left as it is: readers count it as the + /// end of any session.json already, and a write over what no read + /// shows could replace a whole record with fewer bytes of it. The + /// caller goes on to the log, as backstop.sh's record_end_in_lock does. + /// True only when the file then reads back as a whole record of these + /// bytes. + func recordSessionEndInLock(lock: RecoveryLockHandle? = RecoveryLock.held) -> Bool { + guard let encoded = sessionEndMarker() else { return false } + let found = lockEndRecord() + if found == .record(encoded) { return true } + if case .unreadable = found { return false } + guard let lock else { return false } + let record = Data("\(Self.lockEndRecordTag) \(encoded)\n".utf8) + guard record.count <= Self.lockRecordWriteLimit else { return false } + _ = lock.replaceContents(with: record, at: paths.recoveryLock.path) + return lockEndRecord() == .record(encoded) && sessionEndRecordedInLock() != nil + } + + /// Before a start writes its session.json over `previous`, the bytes + /// of the session.json there now (nil when there is none): leaves the + /// recovery lock file holding nothing that can count as the end of the + /// new session, and, while `previous` is still on disk, everything that + /// counts as its end. Nothing, a whole record of `previous`, or that + /// record with bytes after it stays: a whole record ends no other + /// session.json. Its first bytes are completed to the whole record, + /// since a short prefix (the tag's first letter, say) is also the first + /// bytes of the new session's record. Anything else, and everything + /// when there is no `previous`, ends nothing now and is emptied. True + /// once that reads back; the caller refuses the start otherwise and + /// puts the file's bytes back (`restoreLockContents`). The start empties + /// the file of `previous`'s record once its session.json is written. + func settleLockForStart(replacing previous: Data?, lock: RecoveryLockHandle? = RecoveryLock.held) -> Bool { + let found = lockEndRecord() + if found == .none { return true } + guard let previous else { return clearLockEndRecord(lock: lock) } + let encoded = previous.base64EncodedString() + if found == .record(encoded) { return true } + if case .unreadable = found { return false } + guard case let .bytes(data) = readLockFile() else { return clearLockEndRecord(lock: lock) } + let whole = Data("\(Self.lockEndRecordTag) \(encoded)\n".utf8) + if data.count > whole.count, data.starts(with: whole) { return true } + guard Self.holdsRecordCutShort(data, of: encoded) else { return clearLockEndRecord(lock: lock) } + guard let lock else { return false } + _ = lock.replaceContents(with: whole, at: paths.recoveryLock.path) + return lockEndRecord() == .record(encoded) + } + + /// Empties the recovery lock file of any record, through `lock`, once + /// the session it may end is gone or replaced. True when nothing is + /// left to empty: the file holds none, or is not a regular file this + /// user owns, which is never written. + func clearLockEndRecord(lock: RecoveryLockHandle? = RecoveryLock.held) -> Bool { + if lockEndRecord() == .none { return true } + guard let lock else { return false } + _ = lock.replaceContents(with: Data(), at: paths.recoveryLock.path) + return lockEndRecord() == .none + } + + /// The recovery lock file's bytes, to put back with + /// `restoreLockContents` when what replaced them is undone: empty for a + /// file that is missing or not a regular file this user owns, which is + /// never written, and for one larger than any record, which ends + /// nothing and is not kept; nil when it cannot be read whole. + func lockContents() -> Data? { + switch readLockFile() { + case .notUsable, .tooLarge: return Data() + case .unreadable: return nil + case let .bytes(data): return data + } + } + + /// Puts `data` back in the recovery lock file through `lock`, keeping + /// what the file shares with it (`RecoveryLockHandle.replaceContents`). + /// True when it then holds exactly those bytes. + func restoreLockContents(_ data: Data, lock: RecoveryLockHandle? = RecoveryLock.held) -> Bool { + if lockContents() == data { return true } + guard let lock else { return false } + _ = lock.replaceContents(with: data, at: paths.recoveryLock.path) + return lockContents() == data + } + + // MARK: The end record in the log + + /// Where the log records the end of the session in session.json + /// (`LogEndRecord`): "insomnia.log" or "insomnia.log.1", the file that + /// holds a whole record of exactly that file's bytes, or nil. A log that + /// cannot be read records nothing here; a rotation keeps it + /// (`LogEndRecord.keepRecords`). Nothing is recorded for a session.json + /// that is missing, unreadable or not a regular file. + func sessionEndRecordedInLog() -> String? { + guard let current = try? readData(from: paths.sessionFile), + let line = LogEndRecord.line(for: current) else { return nil } + for url in [paths.logFile, OwnerOnly.rotated(paths.logFile)] { + if case .read(found: true, _) = LogEndRecord.scan(url, for: line) { return url.lastPathComponent } + } + return nil + } + + /// For an end that could not remove session.json and could write + /// neither ended-session.json, the journal, a record aside nor the + /// recovery lock file: the record of its bytes appended to insomnia.log, + /// only while `lock` is the recovery lock this transaction holds, and + /// under `Log.withFileLock`, so no line or rotation from this process + /// runs between the write and the read-back. A record already in either + /// log for these bytes is used again. True only when a whole record of + /// session.json's bytes then reads back. + func recordSessionEndInLog(lock: RecoveryLockHandle? = RecoveryLock.held) -> Bool { + if sessionEndRecordedInLog() != nil { return true } + guard let lock, lock.locks(path: paths.recoveryLock.path), + let current = try? readData(from: paths.sessionFile), + let line = LogEndRecord.line(for: current) else { return false } + let logFile = paths.logFile + guard Log.withFileLock({ LogEndRecord.append(line, to: logFile) }) else { return false } + return sessionEndRecordedInLog() != nil + } + + /// What `sessionEndRecordedInLog` reads, as device, inode, size and + /// modification time of session.json (followed, as it is read) and both + /// logs (not followed), so the 1 Hz tick reads the logs again only after + /// one of them changed. + func logEndRecordFingerprint() -> String { + [(paths.sessionFile, true), (paths.logFile, false), (OwnerOnly.rotated(paths.logFile), false)].map { url, follow in + var st = stat() + guard (follow ? stat(url.path, &st) : lstat(url.path, &st)) == 0 else { return "-" } + return "\(st.st_dev):\(st.st_ino):\(st.st_size):\(st.st_mtimespec.tv_sec).\(st.st_mtimespec.tv_nsec)" + }.joined(separator: " ") + } + /// Whether anything is at session.json, a dangling symlink included. /// lstat(2) only: the entry is never opened. func sessionEntryExists() -> Bool { @@ -155,14 +697,25 @@ struct Store: Sendable { /// Never overwrites: a taken name gets -1, -2, ..., and the rename /// itself fails rather than replace a file that appeared meanwhile. func moveAsideUnreadableSession(now: Date) throws -> URL { - let base = Paths.unreadableSessionPrefix + Self.stamp(now) + try moveAside(paths.sessionFile, prefix: Paths.unreadableSessionPrefix, now: now) + } + + /// The same for a config.json that does not decode (see Paths. + /// unreadableConfigPrefix): it holds the user's settings, so it is kept + /// for a person to fix rather than written over with defaults. + func moveAsideUnreadableConfig(now: Date) throws -> URL { + try moveAside(paths.configFile, prefix: Paths.unreadableConfigPrefix, now: now) + } + + private func moveAside(_ file: URL, prefix: String, now: Date) throws -> URL { + let base = prefix + Self.stamp(now) var dest = paths.appSupport.appendingPathComponent(base) var n = 0 while FileManager.default.fileExists(atPath: dest.path) { n += 1 dest = paths.appSupport.appendingPathComponent("\(base)-\(n)") } - try FileManager.default.moveItem(at: paths.sessionFile, to: dest) + try FileManager.default.moveItem(at: file, to: dest) return dest } @@ -181,16 +734,46 @@ struct Store: Sendable { /// names the file so a person can fix or move it. func loadState() throws -> RuntimeState? { do { - return try read(RuntimeState.self, from: paths.stateFile) + guard let data = try readData(from: paths.stateFile) else { return nil } + return try Self.decodeState(data) } catch let error as DecodingError { throw StoreError.corrupt(file: paths.stateFile.path, detail: Self.brief(error)) } } + + /// state.json's bytes as the app reads them: the one decoder behind + /// `loadState` and `Insomnia --agent-session-cutoffs` + /// (AgentCutoffsCommand), which backstop.sh runs on the same bytes + /// before it uses the journal's cutoffs. Pure: it opens no file. + static func decodeState(_ data: Data) throws -> RuntimeState { + try makeDecoder().decode(RuntimeState.self, from: data) + } func saveState(_ s: RuntimeState) throws { try write(s, to: paths.stateFile) } - func loadConfig() throws -> Config? { try read(Config.self, from: paths.configFile) } + /// Throws StoreError.unreadable, with a one-line reason, when the file + /// does not decode. + func loadConfig() throws -> Config? { + guard let data = try readData(from: paths.configFile) else { return nil } + do { + return try Self.decodeConfig(data) + } catch let error as DecodingError { + throw StoreError.unreadable(file: paths.configFile.path, detail: Self.brief(error)) + } + } + + /// config.json's bytes as the app reads them: the one decoder behind + /// `loadConfig` and `Insomnia --agent-cutoffs` (AgentCutoffsCommand), + /// which backstop.sh runs on the same bytes. Pure: it opens no file. + static func decodeConfig(_ data: Data) throws -> Config { + try makeDecoder().decode(Config.self, from: data) + } func saveConfig(_ c: Config) throws { try write(c, to: paths.configFile) } + /// False for a config.json written before `configVersion` existed. + func configHasVersion() throws -> Bool { + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: paths.configFile)) + return (object as? [String: Any])?["configVersion"] != nil + } /// nil when there is none, or it cannot be read: it only names the /// command in a message. func loadUnfinishedCommand() -> UnfinishedCommandRecord? { @@ -220,6 +803,7 @@ enum StoreError: Error, LocalizedError { case corrupt(file: String, detail: String) case unreadable(file: String, detail: String) case notRegularFile(file: String) + case lockRecordNotCleared(file: String) var errorDescription: String? { switch self { @@ -231,6 +815,8 @@ enum StoreError: Error, LocalizedError { return "\(file) could not be decoded (\(detail))" case let .notRegularFile(file): return "\(file) is not a regular file; it was not opened" + case let .lockRecordNotCleared(file): + return "\(file) holds bytes that may record a session's end and could not be rewritten" } } } diff --git a/Sources/Insomnia/System/Notifier.swift b/Sources/Insomnia/System/Notifier.swift index bd6cf11a..5aca0da1 100644 --- a/Sources/Insomnia/System/Notifier.swift +++ b/Sources/Insomnia/System/Notifier.swift @@ -3,6 +3,13 @@ import UserNotifications protocol Notifying: Sendable { func post(title: String, body: String) + /// For a process about to exit: returns once the system has taken the + /// notification, not when it is only queued in this process. + func postBeforeExit(title: String, body: String) async +} + +extension Notifying { + func postBeforeExit(title: String, body: String) async { post(title: title, body: body) } } /// Records posts; for tests and for SessionManager's default. @@ -55,19 +62,34 @@ final class Notifier: Notifying, @unchecked Sendable { } func post(title: String, body: String) { - Log.info("notify: \(title) - \(body)") - guard available else { return } - requestAuthorizationIfNeeded() - let content = UNMutableNotificationContent() - content.title = title - content.body = body - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + guard let request = request(title: title, body: body) else { return } UNUserNotificationCenter.current().add(request) { error in if let error { Log.error("notification post failed: \(error.localizedDescription)") } } } + + func postBeforeExit(title: String, body: String) async { + guard let request = request(title: title, body: body) else { return } + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + Log.error("notification post failed: \(error.localizedDescription)") + } + } + + /// Logs the notification and builds its request; nil outside an app + /// bundle, where the log line is all there is. + private func request(title: String, body: String) -> UNNotificationRequest? { + Log.info("notify: \(title) - \(body)") + guard available else { return nil } + requestAuthorizationIfNeeded() + let content = UNMutableNotificationContent() + content.title = title + content.body = body + return UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + } } /// Presents Insomnia's notifications while Insomnia is the active app. diff --git a/Sources/Insomnia/UI/DurationInput.swift b/Sources/Insomnia/UI/DurationInput.swift index 62f131ba..fb85f732 100644 --- a/Sources/Insomnia/UI/DurationInput.swift +++ b/Sources/Insomnia/UI/DurationInput.swift @@ -20,10 +20,12 @@ struct DurationInput: Equatable, Sendable { } } - /// Tooltip on the "?" badge. - var help: String { + /// Tooltip on the "?" badge. Days names the configured maximum + /// session (`Config.maxDuration`), not the pill's own 30-day entry + /// ceiling: that is the number a session is held to. + func help(maxDuration: TimeInterval) -> String { switch self { - case .days: return "Up to \(DurationInput.maxDays) days" + case .days: return "Up to \(exactLabel(for: maxDuration)) per session" case .hours: return "0\u{2013}\(DurationInput.maxHours)" case .minutes: return "0\u{2013}\(DurationInput.maxMinutes)" } diff --git a/Sources/Insomnia/UI/MenuBarModel.swift b/Sources/Insomnia/UI/MenuBarModel.swift index 9531a49f..ab02f667 100644 --- a/Sources/Insomnia/UI/MenuBarModel.swift +++ b/Sources/Insomnia/UI/MenuBarModel.swift @@ -22,15 +22,32 @@ final class MenuBarModel { case run(TimeInterval) /// Nothing to act on: shake the focused pill. case reject + /// The time would end past the maximum session (`Config.maxDuration` + /// from now): shake and say how much still fits, never shorten it + /// without saying so. `allowed` is what could be entered instead. + case tooLong(allowed: TimeInterval) } /// Bare Enter with every pill empty starts the default preset, so the /// common case is one keystroke. While extending there is no sensible - /// default duration, so it shakes instead. - static func commitAction(mode: Mode, typed: TimeInterval?, defaultPreset: TimeInterval) -> CommitAction { - if let typed { return .run(typed) } + /// default duration, so it shakes instead. A time that would put the end + /// past `maxDuration` from now (`remaining` is the live session's time + /// left while extending) is refused with the allowance rather than + /// clamped: the user typed a number and gets it, or hears why not. + static func commitAction(mode: Mode, typed: TimeInterval?, defaultPreset: TimeInterval, maxDuration: TimeInterval, remaining: TimeInterval = 0) -> CommitAction { + let allowed = max(maxDuration, SessionMath.minimumDuration) - max(remaining, 0) + if let typed { + return typed > allowed ? .tooLong(allowed: allowed) : .run(typed) + } guard mode == .start, defaultPreset > 0 else { return .reject } - return .run(defaultPreset) + return defaultPreset > allowed ? .tooLong(allowed: allowed) : .run(defaultPreset) + } + + /// Label beside the pills for a refused `tooLong`: "Up to 1d", "Up to + /// 1d30m" (every unit that still fits, floored to the minute, so the + /// user can type it back), or "At the maximum" once nothing more fits. + static func tooLongText(allowed: TimeInterval) -> String { + allowed >= SessionMath.minimumDuration ? "Up to \(exactLabel(for: allowed))" : "At the maximum" } enum Phase: Equatable, Sendable { @@ -61,8 +78,8 @@ final class MenuBarModel { static let startFailedText = "Couldn\u{2019}t start" var phase: Phase = .idle - /// Concise start failure shown beside the pills; cleared on the next - /// commit, open or collapse. + /// Concise start failure, or a refused over-maximum time, shown beside + /// the pills; cleared on the next commit, open or collapse. var startError: String? /// The label is drawn only while the pills are up: a retry (Enter) /// hides it at once but keeps its text, and so its room in the layout, diff --git a/Sources/Insomnia/UI/PillView.swift b/Sources/Insomnia/UI/PillView.swift index ba06063d..0504958f 100644 --- a/Sources/Insomnia/UI/PillView.swift +++ b/Sources/Insomnia/UI/PillView.swift @@ -19,6 +19,8 @@ struct PillView: View { let focusBounce: Int let rejectBounce: Int let reduceMotion: Bool + /// `Config.maxDuration`, for the Days tooltip. + let maxDuration: TimeInterval let onTap: () -> Void private var shape: RoundedRectangle { RoundedRectangle(cornerRadius: 7, style: .continuous) } @@ -58,7 +60,7 @@ struct PillView: View { } .environment(\.colorScheme, .dark) .contentShape(Rectangle()) - .help(field.help) + .help(field.help(maxDuration: maxDuration)) .onTapGesture(perform: onTap) .phaseAnimator([CGFloat(1), Motion.bounceScale(reduceMotion: reduceMotion), 1], trigger: focused ? focusBounce : 0) { content, scale in content.scaleEffect(scale) diff --git a/Sources/Insomnia/UI/SettingsView.swift b/Sources/Insomnia/UI/SettingsView.swift index 9a18a0f7..9cd68721 100644 --- a/Sources/Insomnia/UI/SettingsView.swift +++ b/Sources/Insomnia/UI/SettingsView.swift @@ -1,8 +1,10 @@ import AppKit import SwiftUI -/// The settings window (spec 10). Every change is written straight through -/// `manager.config` to config.json. +/// The settings window (spec 10). Every change goes through +/// `SessionManager.updateConfig`, which writes it to config.json; a change to +/// the end floor or the thermal rules takes effect only once that write +/// succeeds. struct SettingsView: View { let manager: SessionManager let secrets: any HotspotSecretStore @@ -59,28 +61,12 @@ struct SettingsView: View { private func bind(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { manager.config[keyPath: keyPath] }, - set: { value in - guard manager.config[keyPath: keyPath] != value else { return } - manager.config[keyPath: keyPath] = value - save() - } + set: { value in update { $0[keyPath: keyPath] = value } } ) } - private func save() { - do { - try manager.store.saveConfig(manager.config) - } catch { - Log.error("could not save config: \(error.localizedDescription)") - } - } - private func update(_ change: (inout Config) -> Void) { - var c = manager.config - change(&c) - guard c != manager.config else { return } - manager.config = c - save() + manager.updateConfig(change) } /// The floor steppers go through the Config setters, which move the @@ -111,7 +97,7 @@ struct SettingsView: View { } } HStack { - TextField("Add preset (30m, 2h, 1h30m, 3d)", text: $newPreset) + TextField("Add preset (30m, 2h, 1h30m, 12h)", text: $newPreset) .textFieldStyle(.roundedBorder) .onSubmit(addPreset) Button("Add", action: addPreset) @@ -131,6 +117,9 @@ struct SettingsView: View { LabeledContent("Maximum session") { Text(chipLabel(for: manager.config.maxDuration)).foregroundStyle(.secondary) } + Text("Sessions and extensions end no later than this. Edit maxDuration in config.json to change it.") + .font(.caption) + .foregroundStyle(.secondary) } } @@ -244,6 +233,11 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) Toggle("Thermal rules (Low Power Mode when hot, end when critical)", isOn: bind(\.thermalRules)) + if let error = manager.configSaveError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } } } diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index 7c0f3f18..ecfaf662 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -480,11 +480,17 @@ final class StatusItemController: NSObject { // Nothing to commit once the monitors are down: Enter has already // been pressed (or Esc), and the slots are retracting. guard keyCatcher != nil else { return } - switch MenuBarModel.commitAction(mode: mode, typed: model.input.total, defaultPreset: manager.config.defaultPreset) { + let remaining = mode == .extend ? (manager.session?.remaining(at: manager.now) ?? 0) : 0 + switch MenuBarModel.commitAction(mode: mode, typed: model.input.total, defaultPreset: manager.config.defaultPreset, maxDuration: manager.config.maxDuration, remaining: remaining) { case let .run(duration): run(mode: mode, duration: duration) case .reject: model.rejectBounce += 1 + case let .tooLong(allowed): + // Said beside the pills, as a refused start is (layout state, + // outside any animation); the typed value stays for editing. + model.startError = MenuBarModel.tooLongText(allowed: allowed) + model.rejectBounce += 1 } } diff --git a/Sources/Insomnia/UI/StatusRootView.swift b/Sources/Insomnia/UI/StatusRootView.swift index 9552ba6b..d5e19e7b 100644 --- a/Sources/Insomnia/UI/StatusRootView.swift +++ b/Sources/Insomnia/UI/StatusRootView.swift @@ -145,6 +145,7 @@ struct StatusRootView: View { focusBounce: model.focusBounce, rejectBounce: model.rejectBounce, reduceMotion: reduceMotion, + maxDuration: manager.config.maxDuration, onTap: { onTapPill(field) } ) .onGeometryChange(for: CGFloat.self) { proxy in @@ -196,8 +197,9 @@ struct StatusRootView: View { .accessibilityLabel(model.phase == .starting ? "Starting session" : countdownText) } - /// The manager refused the start: say so next to the pills the value is - /// still in. The full reason lives in the right-click menu. + /// The manager refused the start, or the time typed is over the maximum + /// session: say so next to the pills the value is still in. The full + /// reason for a refused start lives in the right-click menu. private func startError(_ text: String) -> some View { Label(text, systemImage: "exclamationmark.triangle.fill") .font(.system(size: 11, weight: .medium, design: .rounded)) @@ -206,6 +208,6 @@ struct StatusRootView: View { .lineLimit(1) .fixedSize() .transition(Motion.errorTransition(reduceMotion: reduceMotion)) - .accessibilityLabel("Start failed") + .accessibilityLabel(text) } } diff --git a/Sources/Insomnia/UI/StatusSource.swift b/Sources/Insomnia/UI/StatusSource.swift index bfc2a785..3f9f4807 100644 --- a/Sources/Insomnia/UI/StatusSource.swift +++ b/Sources/Insomnia/UI/StatusSource.swift @@ -142,3 +142,14 @@ enum WiFiStatusName { func chipLabel(for seconds: TimeInterval) -> String { SessionMath.formatRemaining(seconds).replacingOccurrences(of: " ", with: "") } + +/// Every unit of a duration, floored to the minute: "1d30m", "23h", "<1m". +/// For a time the user may type back into the pills (the allowance beside +/// them, the Days tooltip), where `chipLabel` would drop the minutes past a +/// day ("1d" for 1d 30m) and hide what still fits. +func exactLabel(for seconds: TimeInterval) -> String { + let minutes = Int(max(seconds, 0) / 60) + guard minutes >= 1 else { return "<1m" } + let units = [(minutes / 1440, "d"), (minutes % 1440 / 60, "h"), (minutes % 60, "m")] + return units.filter { $0.0 > 0 }.map { "\($0.0)\($0.1)" }.joined() +} diff --git a/Sources/Insomnia/main.swift b/Sources/Insomnia/main.swift index ec2613bf..e124e499 100644 --- a/Sources/Insomnia/main.swift +++ b/Sources/Insomnia/main.swift @@ -2,9 +2,12 @@ import Foundation // Entry point. A one-shot command-line mode is answered here, before AppKit // or SwiftUI start, so backstop.sh can borrow the app's kernel-level -// identity check (see ResumeFrozenCommand), which ends itself when its +// identity check (see ResumeFrozenCommand) and its config.json and +// state.json decoders (see AgentCutoffsCommand); each ends itself when its // lifetime is up. Anything else starts the menu bar app. -if let output = ResumeFrozenCommand.run(Array(CommandLine.arguments.dropFirst()), endAfter: ResumeFrozenCommand.endProcess) { +let arguments = Array(CommandLine.arguments.dropFirst()) +if let output = ResumeFrozenCommand.run(arguments, endAfter: ResumeFrozenCommand.endProcess) + ?? AgentCutoffsCommand.run(arguments, endAfter: ResumeFrozenCommand.endProcess) { for line in output.lines { print(line) } exit(output.status) } diff --git a/Tests/InsomniaTestHome/InsomniaTestHome.c b/Tests/InsomniaTestHome/InsomniaTestHome.c index d71e7ac0..e6ea2d6b 100644 --- a/Tests/InsomniaTestHome/InsomniaTestHome.c +++ b/Tests/InsomniaTestHome/InsomniaTestHome.c @@ -3,7 +3,18 @@ // the run uses. Log.append and SessionManager.live resolve INSOMNIA_HOME // at call time and fall back to the real ~/Library when it is unset; with // the variable set here, no test, filtered or not, can write there. -// The directory is removed when the process exits normally. +// +// The directory goes in TMPDIR when the run sets it, so a runner that points +// TMPDIR at its own folder finds the home there. Without TMPDIR it goes in +// Darwin's per-user temp directory, then /tmp. Fixtures make their files in +// the same parent (insomnia_test_home_temp_dir), since Foundation's +// temporaryDirectory is Darwin's per-user one whatever TMPDIR says. +// The parent must be an existing directory, given as an absolute path, that +// this user owns and no one else can write to, or a sticky directory root +// owns, as /tmp is. Any other parent stops the bundle: a TMPDIR the run set +// is never swapped for another folder. The directory is removed when the +// process exits normally, and only if the path still names the directory +// made here. #include "InsomniaTestHome.h" @@ -11,44 +22,102 @@ #include #include #include +#include #include static const char *const kKey = "INSOMNIA_HOME"; static char g_root[1024]; +static char g_tmp[1024]; static int g_have_root = 0; static pid_t g_owner = 0; +static dev_t g_dev = 0; +static ino_t g_ino = 0; const char *insomnia_test_home_key(void) { return kKey; } const char *insomnia_test_home_root(void) { return g_have_root ? g_root : NULL; } +const char *insomnia_test_home_temp_dir(void) { return g_have_root ? g_tmp : NULL; } + static void remove_root(void) { // Not in a forked child; only the process that made the directory removes it. if (!g_have_root || getpid() != g_owner) return; - removefile(g_root, NULL, REMOVEFILE_RECURSIVE); + struct stat st; + if (lstat(g_root, &st) != 0 || !S_ISDIR(st.st_mode) || st.st_dev != g_dev || st.st_ino != g_ino) { + fprintf(stderr, "InsomniaTestHome: %s is no longer the directory this process made; leaving it\n", g_root); + return; + } + if (removefile(g_root, NULL, REMOVEFILE_RECURSIVE) != 0) perror("InsomniaTestHome: removefile"); +} + +static void refuse(const char *why, const char *path) { + fprintf(stderr, "InsomniaTestHome: %s: %s\n", why, path); + fprintf(stderr, "InsomniaTestHome: refusing to run tests against the real ~/Library\n"); + abort(); +} + +// Why `st`, the parent's stat, rules it out, or NULL when the home may go there. +static const char *parent_problem(const struct stat *st) { + if (!S_ISDIR(st->st_mode)) return "not a directory"; + if (st->st_uid == getuid()) { + if ((st->st_mode & (S_IWGRP | S_IWOTH)) != 0 && (st->st_mode & S_ISVTX) == 0) + return "others can write to it and it is not sticky"; + return NULL; + } + if (st->st_uid == 0 && (st->st_mode & S_ISVTX) != 0) return NULL; + return "owned by another user"; } __attribute__((constructor)) static void insomnia_test_home_install(void) { char tmp[1024]; - size_t n = confstr(_CS_DARWIN_USER_TEMP_DIR, tmp, sizeof tmp); - if (n == 0 || n > sizeof tmp) { - const char *env = getenv("TMPDIR"); - snprintf(tmp, sizeof tmp, "%s", (env && *env) ? env : "/tmp/"); + const char *env = getenv("TMPDIR"); + if (env && *env) { + if (snprintf(tmp, sizeof tmp, "%s", env) >= (int)sizeof tmp) refuse("TMPDIR is too long", env); + } else { + size_t n = confstr(_CS_DARWIN_USER_TEMP_DIR, tmp, sizeof tmp); + if (n == 0 || n > sizeof tmp) snprintf(tmp, sizeof tmp, "%s", "/tmp/"); } size_t len = strlen(tmp); - if (len > 0 && tmp[len - 1] == '/') tmp[len - 1] = '\0'; + while (len > 1 && tmp[len - 1] == '/') tmp[--len] = '\0'; + if (tmp[0] != '/') refuse("the temp directory is not an absolute path", tmp); + + struct stat parent; + if (stat(tmp, &parent) != 0) { + perror("InsomniaTestHome: stat"); + refuse("the temp directory cannot be used", tmp); + } + const char *problem = parent_problem(&parent); + if (problem) refuse(problem, tmp); - snprintf(g_root, sizeof g_root, "%s/insomnia-tests-process-%d-XXXXXX", tmp, (int)getpid()); + const char *sep = (len == 1) ? "" : "/"; + if (snprintf(g_root, sizeof g_root, "%s%sinsomnia-tests-process-%d-XXXXXX", tmp, sep, (int)getpid()) >= (int)sizeof g_root) + refuse("the temp directory path is too long", tmp); if (mkdtemp(g_root) == NULL) { perror("InsomniaTestHome: mkdtemp"); - fprintf(stderr, "InsomniaTestHome: refusing to run tests against the real ~/Library\n"); - abort(); + refuse("cannot make a directory in", tmp); + } + + // The new directory is this user's, private, and sits in the parent + // checked above. On any mismatch remove it if it is still an empty + // directory (rmdir removes nothing else) and stop. + struct stat made, up; + char above[1100]; + snprintf(above, sizeof above, "%s/..", g_root); + if (lstat(g_root, &made) != 0 || !S_ISDIR(made.st_mode) || made.st_uid != getuid() + || (made.st_mode & 07777) != 0700 || stat(above, &up) != 0 + || up.st_dev != parent.st_dev || up.st_ino != parent.st_ino) { + rmdir(g_root); + refuse("the new directory is not the private one made in the temp directory", g_root); } if (setenv(kKey, g_root, 1) != 0) { perror("InsomniaTestHome: setenv"); - abort(); + rmdir(g_root); + refuse("cannot set INSOMNIA_HOME to", g_root); } + memcpy(g_tmp, tmp, len + 1); + g_dev = made.st_dev; + g_ino = made.st_ino; g_have_root = 1; g_owner = getpid(); atexit(remove_root); diff --git a/Tests/InsomniaTestHome/include/InsomniaTestHome.h b/Tests/InsomniaTestHome/include/InsomniaTestHome.h index d4d9106f..270f9c5a 100644 --- a/Tests/InsomniaTestHome/include/InsomniaTestHome.h +++ b/Tests/InsomniaTestHome/include/InsomniaTestHome.h @@ -8,4 +8,9 @@ const char *insomnia_test_home_key(void); /// loaded, or NULL if the loader has not run. const char *insomnia_test_home_root(void); +/// The directory the loader made that one in, without a trailing slash: +/// TMPDIR when the run set it, else Darwin's per-user temp directory, then +/// /tmp. NULL if the loader has not run. +const char *insomnia_test_home_temp_dir(void); + #endif diff --git a/Tests/InsomniaTests/AgentCutoffsCommandTests.swift b/Tests/InsomniaTests/AgentCutoffsCommandTests.swift new file mode 100644 index 00000000..fed896ed --- /dev/null +++ b/Tests/InsomniaTests/AgentCutoffsCommandTests.swift @@ -0,0 +1,379 @@ +import Darwin +import Foundation +import XCTest +@testable import Insomnia + +/// `Insomnia --agent-cutoffs` and `--agent-session-cutoffs`: the one-shot +/// modes backstop.sh runs to read config.json, and the cutoffs the journal +/// records for the session, with the app's decoder. The mapping is tested +/// in process with injected input; the built binary is run with its input +/// in a file or an open pipe, in a temp INSOMNIA_HOME and HOME that it must +/// leave empty. +final class AgentCutoffsCommandTests: XCTestCase { + /// Runs the mode in process with `input`. The lifetime is recorded, + /// never armed: an alarm would end the test runner. + private func run(_ arguments: [String], input: Data?, armed: Locked<[UInt32]> = Locked([])) -> ResumeFrozenCommand.Output? { + AgentCutoffsCommand.run(arguments, input: { input }, endAfter: { armed.value.append($0) }) + } + + /// Other command lines are not this mode, and standard input is not + /// read for them. + func testOtherCommandLinesAreNotThisMode() { + let read = Locked(0) + let armed = Locked<[UInt32]>([]) + let input: () -> Data? = { read.value += 1; return Data("{}".utf8) } + let arm: (UInt32) -> Void = { armed.value.append($0) } + for arguments in [[], ["--resume-frozen", "30"], ["30", "--agent-cutoffs"], ["-NSDocumentRevisionsDebugMode", "YES"]] { + XCTAssertNil(AgentCutoffsCommand.run(arguments, input: input, endAfter: arm), "\(arguments)") + } + XCTAssertEqual(read.value, 0) + XCTAssertEqual(armed.value, [], "the menu bar app must never get a lifetime") + } + + /// A missing or malformed lifetime, or any argument after it, answers + /// "usage" with EX_USAGE before standard input is read. + func testBadArgumentsAreAUsageErrorBeforeAnythingIsRead() { + for arguments in [["--agent-cutoffs"], ["--agent-cutoffs", "0"], ["--agent-cutoffs", "301"], ["--agent-cutoffs", "x"], + ["--agent-cutoffs", "-5"], ["--agent-cutoffs", "0030"], ["--agent-cutoffs", "30", "30"], + ["--agent-session-cutoffs"], ["--agent-session-cutoffs", "0"], ["--agent-session-cutoffs", "30", "30"]] { + let read = Locked(0) + let armed = Locked<[UInt32]>([]) + let out = AgentCutoffsCommand.run(arguments, input: { read.value += 1; return Data("{}".utf8) }, endAfter: { armed.value.append($0) }) + XCTAssertEqual(out, .init(lines: ["usage"], status: AgentCutoffsCommand.usageStatus), "\(arguments)") + XCTAssertEqual(read.value, 0, "\(arguments)") + XCTAssertEqual(armed.value, [], "\(arguments)") + } + } + + /// The lifetime is armed before standard input is read, so a caller + /// that never closes it cannot keep the process waiting. + func testTheLifetimeIsArmedBeforeStandardInputIsRead() { + let order = Locked<[String]>([]) + let out = AgentCutoffsCommand.run(["--agent-cutoffs", "33"], + input: { order.value.append("read"); return Data("{}".utf8) }, + endAfter: { order.value.append("armed \($0)") }) + XCTAssertEqual(order.value, ["armed 33", "read"]) + XCTAssertEqual(out, .init(lines: ["cutoffs 10 true"], status: 0)) + } + + func testInputThatCannotBeReadIsUnreadable() { + XCTAssertEqual(run(["--agent-cutoffs", "5"], input: nil), .init(lines: ["unreadable"], status: AgentCutoffsCommand.unreadableStatus)) + XCTAssertEqual(run(["--agent-session-cutoffs", "5"], input: nil), .init(lines: ["unreadable"], status: AgentCutoffsCommand.unreadableStatus)) + } + + /// Each mode reads its own file: config.json's bytes are no journal + /// record, and the journal's are no config.json. + func testEachModeAnswersForItsOwnFile() { + let armed = Locked<[UInt32]>([]) + XCTAssertEqual(run(["--agent-session-cutoffs", "33"], input: Data(#"{"sessionCutoffs":"30 false"}"#.utf8), armed: armed), + .init(lines: ["cutoffs 30 false"], status: 0)) + XCTAssertEqual(armed.value, [33]) + XCTAssertEqual(run(["--agent-session-cutoffs", "33"], input: Data(#"{"endFloor":30,"thermalRules":false}"#.utf8)), + .init(lines: ["none"], status: 0)) + XCTAssertEqual(run(["--agent-cutoffs", "33"], input: Data(#"{"sessionCutoffs":"30 false"}"#.utf8)), + .init(lines: ["cutoffs 10 true"], status: 0)) + } + + /// The answer is `Store.decodeConfig` on the same bytes, as + /// `Store.loadConfig` reads the file: the first of two duplicate keys, + /// escaped key names, numbers the decoder rounds, and any field's + /// error rejecting the whole file. + func testTheAnswerIsTheAppsDecoding() throws { + let cases: [(text: String, answer: String)] = [ + (#"{"endFloor":30,"thermalRules":false}"#, "cutoffs 30 false"), + (#"{"endFloor":95,"endFloor":0,"thermalRules":false}"#, "cutoffs 95 false"), + (#"{"endFloor":0,"endFloor":95,"thermalRules":false}"#, "cutoffs 0 false"), + (#"{"end\u0046loor":95,"endFloor":0}"#, "cutoffs 95 true"), + (#"{"endFloor":95,"end\u0046loor":0}"#, "cutoffs 95 true"), + (#"{"thermal\u0052ules":false,"thermalRules":true}"#, "cutoffs 10 false"), + (#"{"thermalRules":true,"thermalRules":false}"#, "cutoffs 10 true"), + (#"{"thermalRules":false,"thermalRules":true}"#, "cutoffs 10 false"), + (#"{"endFloor":1e-400}"#, "cutoffs 0 true"), + (#"{"endFloor":4.9999999999999999}"#, "cutoffs 5 true"), + (#"{"endFloor":9223372036854775807}"#, "cutoffs 95 true"), + (#"{"endFloor":-9223372036854775808}"#, "cutoffs 0 true"), + ("{}", "cutoffs 10 true"), + (" \n{ \"endFloor\" : 20 }\n", "cutoffs 20 true"), + (#"{"endFloor":0,"thermalRules":false,"lowPowerFloor":"bad"}"#, "rejected"), + (#"{"endFloor":0,"thermalRules":false,"presets":["bad"]}"#, "rejected"), + (#"{"endFloor":0,"thermalRules":false,"lowPowerFloor":-9223372036854775809}"#, "rejected"), + (#"{"endFloor":-9223372036854775809}"#, "rejected"), + (#"{"endFloor":30.5}"#, "rejected"), + (#"{"endFloor":"30"}"#, "rejected"), + (#"{"endFloor":+30}"#, "rejected"), + (#"{"thermalRules":"false"}"#, "rejected"), + (#"{"endFloor":30} trailing"#, "rejected"), + (#"["endFloor",30]"#, "rejected"), + ("", "rejected"), + ("not json", "rejected"), + (#"endFloor0"#, "rejected"), + ] + let home = TempHome() + defer { home.destroy() } + try home.paths.createDirectories() + let store = Store(paths: home.paths) + for (text, answer) in cases { + let data = Data(text.utf8) + let out = AgentCutoffsCommand.answer(for: data) + XCTAssertEqual(out.lines, [answer], text) + XCTAssertEqual(out.status, answer == "rejected" ? AgentCutoffsCommand.rejectedStatus : 0, text) + try data.write(to: home.paths.configFile) + let app = try? store.loadConfig()?.agentCutoffs + XCTAssertEqual(app.map { "cutoffs \($0.endFloor) \($0.thermalRules)" } ?? "rejected", answer, "Store.loadConfig on \(text)") + } + } + + /// The journal's sessionCutoffs as the agent's mode reads it, and as + /// the app reads it (`Store.loadState`): the same cutoffs where the + /// value is one the app writes, through the same decoder, so duplicate + /// and escaped keys pick the same value. Absent or null is none, a + /// session an older build started. Any other value is `foreign`, which + /// the agent reads as no record, as the app does (the app's defaults + /// where config.json is missing or rejected), and the app records its + /// own over it at its next transaction. + /// A journal the app does not load is `rejected`, whatever its + /// sessionCutoffs: one that is not a JSON object, or has another key + /// the app's decoder refuses, including the first of two copies. + func testTheSessionAnswerIsTheAppsReadingOfTheJournal() throws { + let journal = #""sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false"# + let cases: [(value: String?, answer: String)] = [ + (#""sessionCutoffs":"30 false""#, "cutoffs 30 false"), + (#""sessionCutoffs":"0 false""#, "cutoffs 0 false"), + (#""sessionCutoffs":"95 true""#, "cutoffs 95 true"), + (#""sessionCutoffs":"5 true""#, "cutoffs 5 true"), + (#""sessionCutoffs":"30 false","sessionCutoffs":"0 true""#, "cutoffs 30 false"), + (#""sessionCutoffs":"0 true","sessionCutoffs":"30 false""#, "cutoffs 0 true"), + (#""session\u0043utoffs":"30 false","sessionCutoffs":"0 true""#, "cutoffs 30 false"), + (#""sessionCutoffs":"30 false","session\u0043utoffs":"0 true""#, "cutoffs 30 false"), + (#""sessionCutoffs":"\u0033\u0030 false""#, "cutoffs 30 false"), + (nil, "none"), + (#""sessionCutoffs":null"#, "none"), + (#""sessioncutoffs":"30 false""#, "none"), + (#""sessionCutoffs":"96 false""#, "foreign"), + (#""sessionCutoffs":"100 true""#, "foreign"), + (#""sessionCutoffs":"-1 true""#, "foreign"), + (#""sessionCutoffs":"+5 true""#, "foreign"), + (#""sessionCutoffs":"030 false""#, "foreign"), + (#""sessionCutoffs":"05 false""#, "foreign"), + (#""sessionCutoffs":"30 false""#, "foreign"), + (#""sessionCutoffs":" 30 false""#, "foreign"), + (#""sessionCutoffs":"30 false ""#, "foreign"), + (#""sessionCutoffs":"30\tfalse""#, "foreign"), + (#""sessionCutoffs":"30 FALSE""#, "foreign"), + (#""sessionCutoffs":"30 off""#, "foreign"), + (#""sessionCutoffs":"30""#, "foreign"), + (#""sessionCutoffs":"""#, "foreign"), + (#""sessionCutoffs":"\u0663\u0660 false""#, "foreign"), + (#""sessionCutoffs":30"#, "foreign"), + (#""sessionCutoffs":true"#, "foreign"), + (#""sessionCutoffs":["30 false"]"#, "foreign"), + (#""sessionCutoffs":{"endFloor":30}"#, "foreign"), + (#""sessionCutoffs":"30 false","sessionCutoffs":30"#, "cutoffs 30 false"), + (#""sessionCutoffs":30,"sessionCutoffs":"30 false""#, "foreign"), + ] + let home = TempHome() + defer { home.destroy() } + try home.paths.createDirectories() + let store = Store(paths: home.paths) + for (value, answer) in cases { + let text = "{" + journal + (value.map { "," + $0 } ?? "") + "}" + let data = Data(text.utf8) + let out = AgentCutoffsCommand.sessionAnswer(for: data) + XCTAssertEqual(out.lines, [answer], text) + XCTAssertEqual(out.status, answer == "foreign" ? AgentCutoffsCommand.rejectedStatus : 0, text) + try data.write(to: home.paths.stateFile) + let app = try store.loadState() + XCTAssertEqual(app?.sleepDisabledByUs, true, "the rest of the journal reads as written: \(text)") + let expected = answer.hasPrefix("cutoffs ") ? AgentCutoffs(journalValue: String(answer.dropFirst("cutoffs ".count))) : nil + XCTAssertEqual(app?.sessionCutoffs, expected, "Store.loadState on \(text)") + } + let rejected = [ + "", "not json", #"["sessionCutoffs","30 false"]"#, #""30 false""#, #"{"sessionCutoffs":"30 false"} trailing"#, + #"{"sleepDisabledByUs":true,"frozenProcesses":"bad","sessionCutoffs":"30 false"}"#, + #"{"sleepDisabledByUs":"yes","sessionCutoffs":"30 false"}"#, + #"{"sessionCutoffs":"30 false","savedKeyboardBrightness":1e39}"#, + #"{"sessionCutoffs":"30 false","frozenProcesses":[{"pid":2147483648}]}"#, + #"{"sessionCutoffs":"30 false","frozenPids":[1.5]}"#, + #"{"sessionCutoffs":"30 false","endedSession":5}"#, + #"{"sessionCutoffs":"30 false","appNapOverrides":[{"previous":true}]}"#, + #"{"sessionCutoffs":"30 false","savedAudioOutputs":[{"deviceUID":"a","volume":0.5}]}"#, + #"{"frozenProcesses":"bad","sessionCutoffs":"30 false","frozenProcesses":[]}"#, + ] + for text in rejected { + XCTAssertEqual(AgentCutoffsCommand.sessionAnswer(for: Data(text.utf8)), .init(lines: ["rejected"], status: AgentCutoffsCommand.rejectedStatus), text) + try Data(text.utf8).write(to: home.paths.stateFile) + XCTAssertThrowsError(try store.loadState(), text) + } + } + + /// `AgentCutoffs.journalValue` round-trips every floor the app can + /// enforce, and its reader takes nothing else. + func testTheJournalFormRoundTripsEveryCutoffAndNothingElse() { + for floor in 0...Config.maxEndFloor { + for rule in [true, false] { + let cutoffs = AgentCutoffs(endFloor: floor, thermalRules: rule) + XCTAssertEqual(AgentCutoffs(journalValue: cutoffs.journalValue), cutoffs, cutoffs.journalValue) + } + } + XCTAssertEqual(AgentCutoffs(endFloor: 30, thermalRules: false).journalValue, "30 false") + for text in ["96 true", "99 false", "00 true", "1 yes", "true 30", "30 true true", "30", "", " ", "1e1 true", "0x1 true", "٣ true"] { + XCTAssertNil(AgentCutoffs(journalValue: text), text) + } + } + + // MARK: The built binary + + private var builtBinary: URL { BuiltApp.binary } + + /// Runs the built binary with `arguments` and `input` in a file on + /// standard input, HOME and INSOMNIA_HOME in a temp dir, and output and + /// error in files. Returns what it printed and the names it left in + /// that temp dir besides the three files. + private func runBinary(_ arguments: [String], input: Data) throws -> (status: Int32, stdout: String, stderr: String, left: [String]) { + guard FileManager.default.isExecutableFile(atPath: builtBinary.path) else { + throw XCTSkip("no built Insomnia executable at \(builtBinary.path)") + } + let home = TempHome() + defer { home.destroy() } + let io = ProcessTestHome.temporaryDirectory.appendingPathComponent("insomnia-tests-io-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: io, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: io) } + let inURL = io.appendingPathComponent("stdin") + let outURL = io.appendingPathComponent("stdout") + let errURL = io.appendingPathComponent("stderr") + try input.write(to: inURL) + FileManager.default.createFile(atPath: outURL.path, contents: nil) + FileManager.default.createFile(atPath: errURL.path, contents: nil) + let stdin = try FileHandle(forReadingFrom: inURL) + let stdout = try FileHandle(forWritingTo: outURL) + let stderr = try FileHandle(forWritingTo: errURL) + defer { try? stdin.close(); try? stdout.close(); try? stderr.close() } + let before = try FileManager.default.contentsOfDirectory(atPath: home.root.path) + let p = Process() + p.executableURL = builtBinary + p.arguments = arguments + p.environment = ["INSOMNIA_HOME": home.root.path, "HOME": home.root.path, "PATH": "/usr/bin:/bin"] + p.standardInput = stdin + p.standardOutput = stdout + p.standardError = stderr + let exit = ProcessExit(p) + try p.run() + exit.wait() + let after = try FileManager.default.contentsOfDirectory(atPath: home.root.path) + return (p.terminationStatus, + (try? String(contentsOf: outURL, encoding: .utf8)) ?? "", + (try? String(contentsOf: errURL, encoding: .utf8)) ?? "", + after.filter { !before.contains($0) }.sorted()) + } + + /// The real binary answers before AppKit starts, prints one line, and + /// writes no file: no config.json, no log, no lock in its home. + func testBuiltBinaryAnswersWithoutStartingTheAppOrWritingAFile() throws { + let cases: [(input: String, line: String, status: Int32)] = [ + (#"{"endFloor":95,"endFloor":0,"thermalRules":false}"#, "cutoffs 95 false", 0), + (#"{"endFloor":4.9999999999999999}"#, "cutoffs 5 true", 0), + (#"{"endFloor":0,"lowPowerFloor":"bad"}"#, "rejected", AgentCutoffsCommand.rejectedStatus), + ] + for c in cases { + let r = try runBinary(["--agent-cutoffs", "30"], input: Data(c.input.utf8)) + XCTAssertEqual(r.status, c.status, c.input) + XCTAssertEqual(r.stdout, c.line + "\n", c.input) + XCTAssertEqual(r.stderr, "", c.input) + XCTAssertEqual(r.left, [], c.input) + } + let usage = try runBinary(["--agent-cutoffs"], input: Data("{}".utf8)) + XCTAssertEqual(usage.status, AgentCutoffsCommand.usageStatus) + XCTAssertEqual(usage.stdout, "usage\n") + XCTAssertTrue(usage.stderr.hasPrefix("usage: Insomnia --agent-cutoffs"), usage.stderr) + XCTAssertEqual(usage.left, []) + } + + /// The same for the journal's cutoffs: one line, no file written. + func testBuiltBinaryAnswersForTheJournalWithoutStartingTheAppOrWritingAFile() throws { + let cases: [(input: String, line: String, status: Int32)] = [ + (#"{"sleepDisabledByUs":true,"sessionCutoffs":"30 false","sessionCutoffs":"0 true"}"#, "cutoffs 30 false", 0), + (#"{"sleepDisabledByUs":true}"#, "none", 0), + (#"{"sleepDisabledByUs":true,"sessionCutoffs":"96 false"}"#, "foreign", AgentCutoffsCommand.rejectedStatus), + (#"{"sleepDisabledByUs":true,"sessionCutoffs":30}"#, "foreign", AgentCutoffsCommand.rejectedStatus), + (#"{"sleepDisabledByUs":true,"frozenProcesses":"bad","sessionCutoffs":"30 false"}"#, "rejected", AgentCutoffsCommand.rejectedStatus), + ] + for c in cases { + let r = try runBinary(["--agent-session-cutoffs", "30"], input: Data(c.input.utf8)) + XCTAssertEqual(r.status, c.status, c.input) + XCTAssertEqual(r.stdout, c.line + "\n", c.input) + XCTAssertEqual(r.stderr, "", c.input) + XCTAssertEqual(r.left, [], c.input) + } + let usage = try runBinary(["--agent-session-cutoffs", "x"], input: Data("{}".utf8)) + XCTAssertEqual(usage.status, AgentCutoffsCommand.usageStatus) + XCTAssertEqual(usage.stdout, "usage\n") + XCTAssertTrue(usage.stderr.hasPrefix("usage: Insomnia --agent-session-cutoffs"), usage.stderr) + XCTAssertTrue(usage.stderr.hasSuffix("< state.json\n"), usage.stderr) + XCTAssertEqual(usage.left, []) + } + + /// Standard input up to `maxInputBytes` is read whole; one byte more is + /// unreadable, whatever it holds. + func testBuiltBinaryReadsUpToTheLimitAndNoMore() throws { + let object = Data(#"{"endFloor":20}"#.utf8) + let padded = Data(repeating: UInt8(ascii: " "), count: AgentCutoffsCommand.maxInputBytes - object.count) + object + let whole = try runBinary(["--agent-cutoffs", "30"], input: padded) + XCTAssertEqual(whole.stdout, "cutoffs 20 true\n") + XCTAssertEqual(whole.status, 0) + let over = try runBinary(["--agent-cutoffs", "30"], input: Data(" ".utf8) + padded) + XCTAssertEqual(over.stdout, "unreadable\n") + XCTAssertEqual(over.status, AgentCutoffsCommand.unreadableStatus) + } + + /// The built binary ends itself with SIGALRM once its lifetime is up, + /// here 1 s while it waits for standard input that never ends. The test + /// sends no signal: a binary that did not end gets end of input when + /// the test closes the pipe after 15 s, answers, and fails the SIGALRM + /// assertion. + func testBuiltBinaryEndsItselfWhenItsLifetimeIsUp() throws { + guard FileManager.default.isExecutableFile(atPath: builtBinary.path) else { + throw XCTSkip("no built Insomnia executable at \(builtBinary.path)") + } + let home = TempHome() + defer { home.destroy() } + let pipe = Pipe() + let p = Process() + p.executableURL = builtBinary + p.arguments = ["--agent-cutoffs", "1"] + p.environment = ["INSOMNIA_HOME": home.root.path, "HOME": home.root.path, "PATH": "/usr/bin:/bin"] + p.standardInput = pipe + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + let exited = DispatchSemaphore(value: 0) + p.terminationHandler = { _ in exited.signal() } + let start = Date() + try p.run() + try pipe.fileHandleForWriting.write(contentsOf: Data(#"{"endFloor":"#.utf8)) + let ended = exited.wait(timeout: .now() + 15) == .success + let seconds = Date().timeIntervalSince(start) + if !ended { + try? pipe.fileHandleForWriting.close() + exited.wait() + } + XCTAssertTrue(ended, "still running after 15 s") + XCTAssertEqual(p.terminationReason, .uncaughtSignal) + XCTAssertEqual(p.terminationStatus, SIGALRM) + XCTAssertGreaterThan(seconds, 0.5, "ended before its lifetime") + } + + // MARK: The interface version + + /// The --agent-cutoffs interface version is the same in the binary, in + /// the bundle's Info.plist, and in backstop.sh, which runs the binary + /// only when the installed bundle declares it. + func testTheAgentCutoffsVersionIsTheSameEverywhere() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let scripts = root.appendingPathComponent("scripts") + let info = root.appendingPathComponent("Resources/Info.plist") + let plist = try XCTUnwrap(try PropertyListSerialization.propertyList(from: Data(contentsOf: info), format: nil) as? [String: Any]) + XCTAssertEqual(plist["InsomniaAgentCutoffsVersion"] as? Int, AgentCutoffsCommand.version) + let text = try String(contentsOf: scripts.appendingPathComponent("backstop.sh"), encoding: .utf8) + let lines = text.split(separator: "\n").filter { $0.hasPrefix("AGENT_CUTOFFS_VERSION=") } + XCTAssertEqual(lines, ["AGENT_CUTOFFS_VERSION=\(AgentCutoffsCommand.version)"]) + } +} diff --git a/Tests/InsomniaTests/AppAliveLockTests.swift b/Tests/InsomniaTests/AppAliveLockTests.swift new file mode 100644 index 00000000..861562c7 --- /dev/null +++ b/Tests/InsomniaTests/AppAliveLockTests.swift @@ -0,0 +1,97 @@ +import Darwin +import Foundation +import XCTest +@testable import Insomnia + +/// The alive lock is a kernel flock on one kept file, held by the app for +/// its whole lifetime and probed by backstop.sh with `lockf -t 0`: acquired +/// means no app is running. The kernel releases it when the holder dies. +final class AppAliveLockTests: XCTestCase { + var home: TempHome! + + override func setUp() { + home = TempHome() + } + + override func tearDown() { home.destroy() } + + private func makeLock() -> AppAliveLock { AppAliveLock(url: home.paths.appAliveFile) } + + /// What backstop.sh runs: 75 while held, 0 when it could take the lock. + private func probe() throws -> Int32 { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/lockf") + p.arguments = ["-k", "-s", "-t", "0", home.paths.appAliveFile.path, "/usr/bin/true"] + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + try p.run() + p.waitUntilExit() + return p.terminationStatus + } + + func testSecondHolderIsRefusedUntilRelease() throws { + let first = makeLock() + XCTAssertTrue(try first.tryAcquire()) + XCTAssertTrue(first.isHeld) + XCTAssertTrue(try first.tryAcquire(), "taking one's own lock again is a no-op") + let second = makeLock() + XCTAssertFalse(try second.tryAcquire()) + XCTAssertFalse(second.isHeld) + first.release() + XCTAssertFalse(first.isHeld) + first.release() + XCTAssertTrue(try second.tryAcquire()) + // The file is kept: the app and the probe must keep locking the same inode. + XCTAssertTrue(FileManager.default.fileExists(atPath: home.paths.appAliveFile.path)) + } + + func testFileIsPrivateAndTheDescriptorIsNotInheritedByChildren() throws { + let lock = makeLock() + XCTAssertTrue(try lock.tryAcquire()) + let attrs = try FileManager.default.attributesOfItem(atPath: lock.path) + XCTAssertEqual((attrs[.posixPermissions] as? NSNumber)?.intValue, 0o600) + let flags = fcntl(lock.fileDescriptor, F_GETFD) + XCTAssertGreaterThanOrEqual(flags, 0) + XCTAssertNotEqual(flags & FD_CLOEXEC, 0, "a child such as pmset or osascript must not keep the app's liveness alive") + } + + func testGoingAwayReleases() throws { + var lock: AppAliveLock? = makeLock() + XCTAssertTrue(try lock!.tryAcquire()) + let other = makeLock() + XCTAssertFalse(try other.tryAcquire()) + lock = nil + XCTAssertTrue(try other.tryAcquire()) + } + + func testAcquireGivesUpAfterTheBoundWhileHeldAndSucceedsOnceReleased() async throws { + let held = makeLock() + XCTAssertTrue(try held.tryAcquire()) + let mine = makeLock() + let started = ContinuousClock.now + let whileHeld = try await mine.acquire(timeout: 0.2) + XCTAssertFalse(whileHeld) + XCTAssertLessThan(ContinuousClock.now - started, .seconds(3)) + XCTAssertFalse(mine.isHeld) + Task.detached { + try? await Task.sleep(for: .milliseconds(150)) + held.release() + } + let afterRelease = try await mine.acquire(timeout: 3) + XCTAssertTrue(afterRelease) + XCTAssertTrue(mine.isHeld) + } + + /// backstop.sh probes with `lockf -k -s -t 0 /usr/bin/true` in + /// another process: 75 (EX_TEMPFAIL) while the app holds the lock, 0 + /// once it is gone, and the probe's own hold is gone with the probe. + func testLockfProbeSeesTheHeldLockAndItsRelease() throws { + let lock = makeLock() + XCTAssertTrue(try lock.tryAcquire()) + XCTAssertEqual(try probe(), 75, "the probe must not get the lock while the app holds it") + lock.release() + XCTAssertEqual(try probe(), 0, "released: the probe takes it, so the app is not running") + XCTAssertTrue(try lock.tryAcquire(), "the probe gave it back") + XCTAssertTrue(FileManager.default.fileExists(atPath: lock.path), "lockf -k keeps the file") + } +} diff --git a/Tests/InsomniaTests/AppNapTests.swift b/Tests/InsomniaTests/AppNapTests.swift index bb4a56c5..2c0542ea 100644 --- a/Tests/InsomniaTests/AppNapTests.swift +++ b/Tests/InsomniaTests/AppNapTests.swift @@ -203,19 +203,27 @@ final class AppNapTests: XCTestCase { /// A journal that cannot be written means no preference write: the /// session still runs, the app's preferences are untouched, and the - /// failure is reported. + /// failure is reported. The journal becomes unwritable once the resume + /// has journaled its sleep guard and is holding sleep, because a resume + /// that cannot write the journal at all is refused (the next test). func testJournalWriteFailureMeansNoPreferenceWrite() async throws { let now = h.clock.now try h.store.saveSession(Session(startedAt: now, endsAt: now.addingTimeInterval(3600))) var st = RuntimeState() st.sleepDisabledByUs = true try h.store.saveState(st) + h.guardFake.sleepDisabled = true // the crashed session's hold let m = makeManager(optIn: true, agents: [chrome, terminal]) let file = h.home.paths.stateFile.path - try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: file) + let gate = AsyncGate() + h.guardFake.sleepGate = gate defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: file) } - await m.reconcile() + let reconcile = Task { await m.reconcile() } + await gate.waitUntilStarted() + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: file) + await gate.open() + await reconcile.value try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: file) XCTAssertTrue(m.isActive, "the session itself is unaffected") @@ -227,6 +235,34 @@ final class AppNapTests: XCTestCase { XCTAssertTrue(err.contains("left unchanged"), err) } + /// A journal that cannot be written when reconcile finds a valid + /// session means no resume at all, even with `sleepDisabledByUs` + /// already set. The write that fails is the one a resume needs, so + /// sleep is not held again and no preference is written. + func testUnwritableJournalAtReconcileResumesNothingAndWritesNoPreference() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now, endsAt: now.addingTimeInterval(3600))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true // the crashed session's hold + let m = makeManager(optIn: true, agents: [chrome, terminal]) + let file = h.home.paths.stateFile.path + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: file) + defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: file) } + + await m.reconcile() + try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: file) + + XCTAssertFalse(m.isActive, "a session resumes only from a journal it can write") + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertEqual(h.appNap.writes.count, 0) + XCTAssertEqual(h.appNap.values, [:]) + XCTAssertEqual(try h.store.loadState()?.appNapOverrides, []) + let log = (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains("could not journal sleep guard"), log) + } + /// A preference write that fails after its entry was journaled keeps /// the entry: the restore puts the recorded value back either way. func testFailedPreferenceWriteKeepsTheEntryForRestore() async throws { @@ -298,6 +334,7 @@ final class AppNapTests: XCTestCase { try h.store.saveState(st) // Chrome: journaled, then the crash came before or after the write. h.appNap.values = [chrome: true, terminal: false] + h.guardFake.sleepDisabled = true // the crashed session's hold let m = makeManager(optIn: true, agents: [chrome, terminal]) await m.reconcile() diff --git a/Tests/InsomniaTests/AutomationSafetyTests.swift b/Tests/InsomniaTests/AutomationSafetyTests.swift index 46e06524..d456d9fc 100644 --- a/Tests/InsomniaTests/AutomationSafetyTests.swift +++ b/Tests/InsomniaTests/AutomationSafetyTests.swift @@ -395,7 +395,7 @@ final class TmuxLiveRunnerTests: XCTestCase { /// reach the pane in order, so once `X` is in the file, everything the /// nudge sent is too, however slow the pane. func testLivePaneGetsContinueWithoutEnterByDefault() async throws { - let received = FileManager.default.temporaryDirectory + let received = ProcessTestHome.temporaryDirectory .appendingPathComponent("insomnia-nudge-\(UUID().uuidString).txt") defer { try? FileManager.default.removeItem(at: received) } // tmux runs the command through sh -c: quote the path for it. diff --git a/Tests/InsomniaTests/CommandCancellationTests.swift b/Tests/InsomniaTests/CommandCancellationTests.swift index 8462b347..996d0efb 100644 --- a/Tests/InsomniaTests/CommandCancellationTests.swift +++ b/Tests/InsomniaTests/CommandCancellationTests.swift @@ -8,7 +8,7 @@ final class CommandCancellationTests: XCTestCase { private var dir: URL! override func setUpWithError() throws { - dir = FileManager.default.temporaryDirectory + dir = ProcessTestHome.temporaryDirectory .appendingPathComponent("insomnia-cmd-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } diff --git a/Tests/InsomniaTests/ConfigTests.swift b/Tests/InsomniaTests/ConfigTests.swift index 1d10bc80..6522ccd4 100644 --- a/Tests/InsomniaTests/ConfigTests.swift +++ b/Tests/InsomniaTests/ConfigTests.swift @@ -14,11 +14,13 @@ final class ConfigTests: XCTestCase { func testDefaults() { let c = Config() - XCTAssertEqual(c.presets, [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200]) + XCTAssertEqual(c.presets, [1800, 3600, 7200, 14400, 28800, 43200, 86400]) XCTAssertEqual(c.lowPowerFloor, 40) XCTAssertEqual(c.endFloor, 10) XCTAssertEqual(c.nudgeThreshold, 90) - XCTAssertEqual(c.maxDuration, 30 * 24 * 3600) + XCTAssertEqual(c.maxDuration, 24 * 3600) + XCTAssertTrue(c.presets.allSatisfy { $0 <= c.maxDuration }, "no shipped preset may exceed the maximum") + XCTAssertLessThanOrEqual(c.defaultPreset, c.maxDuration) XCTAssertEqual(c.freezeList, ["com.tinyspeck.slackmacgap", "net.whatsapp.WhatsApp", "com.hnc.Discord"]) XCTAssertTrue(c.agentList.contains("com.apple.Terminal")) XCTAssertTrue(c.agentList.contains("com.t3tools.t3code")) @@ -169,6 +171,125 @@ final class ConfigTests: XCTestCase { XCTAssertTrue(old.muteOnLidClose) } + /// The 24-hour ceiling is the decoder's default too, so an older + /// config.json without the key gets it; a written value is kept, so a + /// user who raised it in config.json keeps the longer sessions. + func testMaxDurationDefaultsTo24HoursAndAnExplicitValueIsKept() throws { + let old = try Store.makeDecoder().decode(Config.self, from: Data(#"{"lowPowerFloor": 25}"#.utf8)) + XCTAssertEqual(old.maxDuration, 24 * 3600) + let raised = try Store.makeDecoder().decode(Config.self, from: Data(#"{"maxDuration": 604800}"#.utf8)) + XCTAssertEqual(raised.maxDuration, 7 * 24 * 3600) + } + + /// Settings saves the whole struct, so every ordinary config.json from an + /// older build holds that build's defaults (30 days, a 3-day preset) as + /// explicit values. Exactly those read as the current defaults; any other + /// value was chosen by hand and is kept, a 3-day preset included, unless + /// the ceiling it sits under was not (see the next test). + func testLegacyDefaultsSavedByOlderBuildsReadAsTheCurrentDefaults() throws { + let saved = #"{"maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200]}"# + let migrated = try Store.makeDecoder().decode(Config.self, from: Data(saved.utf8)) + XCTAssertEqual(migrated.maxDuration, 24 * 3600) + XCTAssertEqual(migrated.presets, Config.defaultPresets) + XCTAssertEqual(migrated, earlierBuild()) + + let custom = #"{"maxDuration": 604800, "presets": [3600, 259200]}"# + let kept = try Store.makeDecoder().decode(Config.self, from: Data(custom.utf8)) + XCTAssertEqual(kept.maxDuration, 7 * 24 * 3600) + XCTAssertEqual(kept.presets, [3600, 259200]) + + // A customized list that still has the old default's shape minus one + // entry is not the old default: it is kept, minus the entry above + // the 24-hour ceiling it now sits under. + let trimmed = #"{"presets": [1800, 3600, 7200, 14400, 28800, 43200, 259200]}"# + XCTAssertEqual(try Store.makeDecoder().decode(Config.self, from: Data(trimmed.utf8)).presets, [1800, 3600, 7200, 14400, 28800, 43200]) + } + + /// An older build could have the 3-day preset as its default. Once the + /// 30-day ceiling the user never chose becomes 24 hours, that default + /// would make bare Enter refuse, so it moves to the largest preset left + /// under the ceiling, and presets above the ceiling go (Settings refuses + /// to add them). A ceiling the user set keeps everything as it was. + @MainActor + func testADefaultAboveTheMigratedCeilingMovesToTheLargestPresetUnderIt() throws { + func decode(_ json: String) throws -> Config { + try Store.makeDecoder().decode(Config.self, from: Data(json.utf8)) + } + func bareEnter(_ c: Config) -> MenuBarModel.CommitAction { + MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: c.defaultPreset, maxDuration: c.maxDuration) + } + + let stock = try decode(#"{"maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200], "defaultPreset": 259200}"#) + XCTAssertEqual(stock.maxDuration, 24 * 3600) + XCTAssertEqual(stock.presets, Config.defaultPresets) + XCTAssertEqual(stock.defaultPreset, 24 * 3600) + XCTAssertEqual(bareEnter(stock), .run(24 * 3600)) + + let trimmed = try decode(#"{"maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 259200], "defaultPreset": 259200}"#) + XCTAssertEqual(trimmed.presets, [1800, 3600, 7200, 14400, 28800, 43200]) + XCTAssertEqual(trimmed.defaultPreset, 12 * 3600) + XCTAssertEqual(bareEnter(trimmed), .run(12 * 3600)) + + // Nothing left under the ceiling: the stock default. + let onlyLong = try decode(#"{"presets": [259200], "defaultPreset": 259200}"#) + XCTAssertEqual(onlyLong.presets, []) + XCTAssertEqual(onlyLong.defaultPreset, Config().defaultPreset) + XCTAssertEqual(bareEnter(onlyLong), .run(Config().defaultPreset)) + + // A default that still fits stays where the user put it. + let fits = try decode(#"{"maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200], "defaultPreset": 7200}"#) + XCTAssertEqual(fits.defaultPreset, 7200) + + // A ceiling set by hand keeps the 3-day default; only the stock list changes. + let raised = try decode(#"{"maxDuration": 604800, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200], "defaultPreset": 259200}"#) + XCTAssertEqual(raised.maxDuration, 7 * 24 * 3600) + XCTAssertEqual(raised.presets, Config.defaultPresets) + XCTAssertEqual(raised.defaultPreset, 3 * 24 * 3600) + XCTAssertEqual(bareEnter(raised), .run(3 * 24 * 3600)) + } + + /// Only a file without `configVersion` can hold an older build's stock + /// values. A current file's 30-day ceiling was set by hand and is kept, + /// with the presets and default under it. + func testACurrentFileKeepsA30DayCeilingSetByHand() throws { + let json = #"{"configVersion": 2, "maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200], "defaultPreset": 259200}"# + let c = try Store.makeDecoder().decode(Config.self, from: Data(json.utf8)) + XCTAssertEqual(c.maxDuration, 30 * 24 * 3600) + XCTAssertEqual(c.presets, Config.legacyPresets) + XCTAssertEqual(c.defaultPreset, 3 * 24 * 3600) + + let written = try XCTUnwrap(JSONSerialization.jsonObject(with: Store.makeEncoder().encode(Config())) as? [String: Any]) + XCTAssertEqual(written["configVersion"] as? Int, 2) + } + + /// The app reads an older file with the stock values migrated and writes + /// it back once with the marker, so a 30-day ceiling typed into that file + /// afterwards is the user's. A current file is not rewritten at launch. + @MainActor + func testAnOlderFileIsWrittenBackOnceSoALaterHandEditIsKept() throws { + let h = Harness() + defer { h.home.destroy() } + let url = h.home.paths.configFile + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"maxDuration": 2592000, "presets": [1800, 3600, 7200, 14400, 28800, 43200, 86400, 259200], "defaultPreset": 259200, "endFloor": 15}"#.utf8).write(to: url) + + let upgraded = h.makeManager() + XCTAssertEqual(upgraded.config.maxDuration, 24 * 3600) + XCTAssertEqual(upgraded.config.defaultPreset, 24 * 3600) + XCTAssertEqual(upgraded.config.endFloor, 15) + var onDisk = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + XCTAssertEqual(onDisk["configVersion"] as? Int, 2) + XCTAssertEqual(onDisk["maxDuration"] as? Double, 24 * 3600) + XCTAssertEqual(onDisk["endFloor"] as? Int, 15) + + onDisk["maxDuration"] = 2592000 + let handEdited = try JSONSerialization.data(withJSONObject: onDisk) + try handEdited.write(to: url) + let later = h.makeManager() + XCTAssertEqual(later.config.maxDuration, 30 * 24 * 3600) + XCTAssertEqual(try Data(contentsOf: url), handEdited, "a current file is read, not rewritten") + } + /// A config.json without the key (older build, or written by hand) /// gets the default, off: an upgrade never opts anyone in. An explicit /// true is honoured and round-trips. @@ -416,6 +537,339 @@ final class ConfigLoadTests: XCTestCase { XCTAssertFalse(log().contains("battery floors corrected"), log()) } + private func writeConfig(_ json: String) throws -> Data { + try h.home.paths.createDirectories() + let data = Data(json.utf8) + try data.write(to: h.home.paths.configFile) + return data + } + + private func movedAsideConfigs() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: h.home.paths.appSupport.path) + .filter { $0.hasPrefix(Paths.unreadableConfigPrefix) }.sorted() + } + + /// The marker's presence is what makes a file current; its value is + /// never read. A hand-edited "2" keeps every setting, the 30-day + /// ceiling included, and the file is neither migrated nor rewritten. + /// The file has had the lid-close update, which would write it once. + func testAVersionMarkerOfAnotherTypeKeepsTheSettings() async throws { + let json = #"{"configVersion": "2", "lidCloseDefaultsApplied": true, "maxDuration": 2592000, "endFloor": 30, "lowPowerFloor": 40, "freezeAllApps": false}"# + let written = try writeConfig(json) + + let m = h.makeManager() + await m.reconcile() + + XCTAssertEqual(m.config.maxDuration, 30 * 24 * 3600) + XCTAssertEqual([m.config.endFloor, m.config.lowPowerFloor], [30, 40]) + XCTAssertFalse(m.config.freezeAllApps) + XCTAssertEqual(try Data(contentsOf: h.home.paths.configFile), written) + XCTAssertEqual(try movedAsideConfigs(), []) + XCTAssertFalse(h.notifier.posts.contains { $0.title == SessionManager.configFileTitle }) + } + + /// A file this build cannot decode is the user's settings with one bad + /// value or a typo. It is renamed aside with its bytes, never written + /// over. The first reconcile, which runs only once the launch holds the + /// alive lock, writes the defaults the app runs on to config.json and + /// says where the file went, once. + func testAConfigThatDoesNotDecodeIsMovedAsideNotOverwritten() async throws { + let cases = [ + #"{"configVersion": 2, "endFloor": "30", "freezeAllApps": false}"#, + #"{"configVersion": 2, "endFloor": 30"#, + ] + for json in cases { + h.home.destroy() + h = Harness() + let written = try writeConfig(json) + + let m = h.makeManager() + + XCTAssertEqual(m.config, Config(), json) + XCTAssertEqual(try movedAsideConfigs(), ["config.json.unreadable-20270115T080000Z"], json) + let moved = h.home.paths.appSupport.appendingPathComponent("config.json.unreadable-20270115T080000Z") + XCTAssertEqual(try Data(contentsOf: moved), written, json) + XCTAssertNil(try h.store.loadConfig(), "init writes nothing: it runs before LaunchGate") + XCTAssertTrue(log().contains("[error] insomnia: config.json could not be read ("), log()) + + await m.reconcile() + XCTAssertEqual(try h.store.loadConfig(), Config(), json) + await m.reconcile() + let notices = h.notifier.posts.filter { $0.title == SessionManager.configFileTitle } + XCTAssertEqual(notices.count, 1, "\(notices)") + XCTAssertTrue(notices.first?.body.contains("It was moved to \(moved.path)") == true, "\(notices)") + } + } + + /// When the rename fails, nothing is written over the file: the app runs + /// on defaults and says the file was left as it is, and how to fix it. + /// The rename keeps failing in reconcile's transaction, which sets + /// `rejectedConfigFile` without a second notification. + func testAConfigThatCannotBeMovedAsideIsLeftAsItIs() async throws { + let written = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + + let m = h.makeManager() + + XCTAssertEqual(m.config, Config()) + XCTAssertEqual(try Data(contentsOf: file), written) + XCTAssertEqual(try movedAsideConfigs(), []) + await m.reconcile() + XCTAssertNotNil(m.rejectedConfigFile) + let notices = h.notifier.posts.filter { $0.title == SessionManager.configFileTitle } + XCTAssertEqual(notices.count, 1, "\(notices)") + XCTAssertTrue(notices.first?.body.contains("left the file as it is") == true, "\(notices)") + XCTAssertTrue(notices.first?.body.contains("Make \(file.path) writable or delete it.") == true, "\(notices)") + } + + // MARK: config.json rejected in place + + /// The app's decoder refuses this file (freezeList is not a list), + /// though its endFloor and thermalRules alone would read as a 0% floor + /// and no thermal rule. backstop.sh reads it through the app's decoder, + /// so the agent enforces the app's defaults for it. + private let rejectedConfig = #"{"endFloor": 0, "thermalRules": false, "freezeList": 42}"# + + /// While a file the app rejects cannot be moved aside, the agent would + /// enforce the app's defaults, which need not be the settings in use, so + /// Start changes nothing and says which file to fix and how. Once the + /// file can be renamed, the next Start moves it aside, writes the + /// settings in use back, and starts. + func testStartIsRefusedWhileARejectedConfigCannotBeMovedAside() async throws { + let written = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + let m = h.makeManager() + await m.reconcile() + + await m.start(duration: 3600) + + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState() ?? .clean, RuntimeState.clean) + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertEqual(h.backstop.arms, 0) + XCTAssertEqual(try Data(contentsOf: file), written) + XCTAssertEqual(try movedAsideConfigs(), []) + XCTAssertTrue(m.lastError?.hasPrefix("start refused, nothing changed: config.json could not be read (") == true, m.lastError ?? "nil") + let refusal = h.notifier.posts.last + XCTAssertEqual(refusal?.title, SessionManager.configFileTitle) + XCTAssertTrue(refusal?.body.hasPrefix("Insomnia did not start a session. config.json could not be read (") == true, "\(String(describing: refusal))") + XCTAssertTrue(refusal?.body.hasSuffix("Make \(file.path) writable or delete it.") == true, "\(String(describing: refusal))") + + try setImmutable(file, false) + await m.start(duration: 3600) + + XCTAssertTrue(m.isActive) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 1")) + XCTAssertNil(m.rejectedConfigFile) + let moved = h.home.paths.appSupport.appendingPathComponent("config.json.unreadable-20270115T080000Z") + XCTAssertEqual(try movedAsideConfigs(), [moved.lastPathComponent]) + XCTAssertEqual(try Data(contentsOf: moved), written) + XCTAssertEqual(try h.store.loadConfig(), m.config) + XCTAssertTrue(h.notifier.posts.contains { $0.title == SessionManager.configFileTitle && $0.body.contains("It was moved to \(moved.path)") }, "\(h.notifier.posts)") + } + + /// Deleting the file is the other fix. Start writes the settings the + /// app fell back to in its place, so the agent reads them, and goes + /// ahead. + func testStartGoesAheadOnceTheRejectedConfigIsDeleted() async throws { + _ = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + + try setImmutable(file, false) + try FileManager.default.removeItem(at: file) + await m.start(duration: 3600) + + XCTAssertTrue(m.isActive) + XCTAssertEqual(m.config, Config()) + XCTAssertEqual(try h.store.loadConfig(), m.config) + XCTAssertEqual(try movedAsideConfigs(), []) + } + + /// The same fix after the file turned bad during a session, while the + /// app runs on a 30% floor: without a file the agent would enforce its + /// own 10%. So the deleted file is replaced by the settings in use + /// before a session runs, and while that write fails Start is refused. + func testADeletedRejectedConfigIsReplacedByTheSettingsInUseBeforeAStart() async throws { + var mine = Config() + mine.endFloor = 30 + try h.store.saveConfig(mine) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + _ = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + let dir = h.home.paths.appSupport + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + await m.extend(by: 600) + XCTAssertFalse(m.isActive) + + try setImmutable(file, false) + try FileManager.default.removeItem(at: file) + try TestACL.denyNewFiles(in: dir) + defer { try? TestACL.removeAll(dir) } + await m.start(duration: 3600) + + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + let why = m.rejectedConfigFile ?? "no refusal" + XCTAssertTrue(why.hasSuffix("Free some disk space or make \(dir.path) writable."), why) + XCTAssertEqual(h.notifier.posts.last?.body, "Insomnia did not start a session. \(why)") + + try TestACL.removeAll(dir) + await m.start(duration: 3600) + + XCTAssertTrue(m.isActive) + XCTAssertNil(m.rejectedConfigFile) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 30) + XCTAssertEqual(try movedAsideConfigs(), []) + } + + /// A session already running when config.json becomes a file the app + /// rejects and cannot move ends at the next transaction, through the + /// normal end: sleep restored, session.json removed, the journal clean, + /// and a notification that names the file. The file is left as it is, + /// and the next Start is refused. + func testARunningSessionEndsAtTheNextTransactionOnceConfigIsRejectedInPlace() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let written = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + + await m.extend(by: 600) + + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState() ?? .clean, RuntimeState.clean) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertEqual(try Data(contentsOf: file), written) + let end = h.notifier.posts.last + XCTAssertEqual(end?.title, "Session ended") + XCTAssertTrue(end?.body.contains("Make \(file.path) writable or delete it.") == true, "\(String(describing: end))") + XCTAssertTrue(log().contains("session end (settingsFileRejected)"), log()) + + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, 1) + } + + /// At launch, a valid session on disk is not resumed while config.json + /// is rejected in place. Reconcile ends it from the journal instead. + func testReconcileEndsAValidSessionInsteadOfResumingItWhileConfigIsRejected() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3600))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true + _ = try writeConfig(rejectedConfig) + let file = h.home.paths.configFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + + let m = h.makeManager() + await m.reconcile() + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + XCTAssertEqual(h.backstop.arms, 0) + let end = h.notifier.posts.last + XCTAssertEqual(end?.title, "Session restored") + XCTAssertTrue(end?.body.contains("Make \(file.path) writable or delete it.") == true, "\(String(describing: end))") + } + + /// A file the app rejects that can be renamed is moved aside at the + /// next transaction, and the settings the app runs on are written in its + /// place, so the agent reads the app's cutoffs again. The session goes + /// on. + func testARejectedConfigThatCanBeMovedIsReplacedByTheSettingsInUse() async throws { + var mine = Config() + mine.endFloor = 30 + try h.store.saveConfig(mine) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + let written = try writeConfig(rejectedConfig) + + await m.extend(by: 600) + + XCTAssertTrue(m.isActive) + XCTAssertEqual(m.config.endFloor, 30) + XCTAssertEqual(try h.store.loadConfig(), m.config) + let moved = h.home.paths.appSupport.appendingPathComponent("config.json.unreadable-20270115T080000Z") + XCTAssertEqual(try Data(contentsOf: moved), written) + XCTAssertTrue(h.notifier.posts.contains { $0.title == SessionManager.configFileTitle && $0.body.contains("It was moved to \(moved.path)") }, "\(h.notifier.posts)") + } + + /// A rejected file moved aside whose replacement cannot be written, as + /// on a full disk, leaves no config.json: the agent would enforce its + /// 10% default floor while the app enforces 30%. The running session + /// ends, Start is refused, and every transaction writes again until the + /// file is there. config.json is a directory here, which the rename + /// moves under the ACL entry that stops the write's temp file. + func testSessionsWaitForTheSettingsInUseToBeWrittenWhereTheRejectedConfigWas() async throws { + var mine = Config() + mine.endFloor = 30 + try h.store.saveConfig(mine) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let file = h.home.paths.configFile + let dir = h.home.paths.appSupport + try FileManager.default.removeItem(at: file) + try FileManager.default.createDirectory(at: file, withIntermediateDirectories: false) + try TestACL.denyNewFiles(in: dir) + defer { try? TestACL.removeAll(dir) } + + await m.extend(by: 600) + + XCTAssertFalse(m.isActive) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + XCTAssertEqual(try movedAsideConfigs(), ["config.json.unreadable-20270115T080000Z"]) + let why = try XCTUnwrap(m.rejectedConfigFile) + XCTAssertTrue(why.hasSuffix("Free some disk space or make \(dir.path) writable."), why) + let moved = dir.appendingPathComponent("config.json.unreadable-20270115T080000Z") + XCTAssertTrue(h.notifier.posts.contains { $0.title == SessionManager.configFileTitle && $0.body.hasSuffix("was moved to \(moved.path). \(why)") }, "\(h.notifier.posts)") + + await m.start(duration: 3600) + + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + XCTAssertEqual(h.notifier.posts.last?.body, "Insomnia did not start a session. \(why)") + + try TestACL.removeAll(dir) + await m.reconcile() + + XCTAssertNil(m.rejectedConfigFile) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 30) + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + } + // MARK: Lid-close update at launch /// config.json as an earlier build left it: no lid-close mark. @@ -509,6 +963,38 @@ final class ConfigLoadTests: XCTestCase { XCTAssertEqual(h.notifier.posts.count, 1, "only the first launch announces the update") } + /// A config.json from before both one-time updates gets each once: the + /// stock 30-day ceiling becomes 24 hours and the lid-close settings + /// change, with one notice, while a value the user set is kept. The + /// file is written back with both marks, so a 30-day ceiling the user + /// then sets and a dismissed notice stay as they are after a relaunch. + func testAnOlderConfigGetsTheDurationAndLidCloseUpdatesOnceEach() async throws { + try writeEarlierBuildConfig(#"{"maxDuration": 2592000, "endFloor": 30, "freezeAllApps": true, "muteOnLidClose": false}"#) + + let m = h.makeManager() + await m.reconcile() + + XCTAssertEqual(m.config.maxDuration, 24 * 3600) + XCTAssertEqual(m.config.endFloor, 30) + XCTAssertFalse(m.config.freezeAllApps) + XCTAssertTrue(m.config.muteOnLidClose) + XCTAssertNotNil(m.config.lidCloseDefaultsNotice) + XCTAssertEqual(try h.store.loadConfig(), m.config) + XCTAssertTrue(try h.store.configHasVersion()) + XCTAssertEqual(h.notifier.posts.map(\.title), [LidCloseDefaultsChange.title]) + + m.config.maxDuration = 30 * 24 * 3600 + try h.store.saveConfig(m.config) + m.dismissLidCloseNotice() + let again = h.makeManager() + await again.reconcile() + + XCTAssertEqual(again.config.maxDuration, 30 * 24 * 3600) + XCTAssertNil(again.config.lidCloseDefaultsNotice) + XCTAssertEqual(again.config, m.config) + XCTAssertEqual(h.notifier.posts.count, 1) + } + /// A fresh install gets the new defaults, a config.json with the mark, /// and no notice. func testFreshInstallGetsTheNewDefaultsWithoutANotice() async throws { diff --git a/Tests/InsomniaTests/CutoffAgreementTests.swift b/Tests/InsomniaTests/CutoffAgreementTests.swift new file mode 100644 index 00000000..67969769 --- /dev/null +++ b/Tests/InsomniaTests/CutoffAgreementTests.swift @@ -0,0 +1,1676 @@ +import Foundation +import XCTest +@testable import Insomnia + +/// The app and the recovery agent each decide the battery and thermal +/// cutoffs: FloorRules on `manager.config`, and backstop.sh on what the +/// app's binary decodes from config.json (`AgentCutoffsCommand`). These +/// tests take the two through each way they could part (a deleted file, a +/// Settings change that could not be saved, a file repaired or edited by +/// hand, duplicate or escaped keys, an end floor far outside 0...95) and +/// then ask both, with the battery at 25% on battery power (or the level a +/// test names) and the thermal pressure level at 3 (critical) or 0. The +/// agent is the real backstop.sh with its tools patched to fakes and this +/// build's binary as the app's, run with the app's alive lock held. +@MainActor +final class CutoffAgreementTests: XCTestCase { + var h: Harness! + var alive: AppAliveLock! + var agent: PatchedBackstop! + let acls = OwnedACLs() + + override func setUp() async throws { + h = Harness() + try h.home.paths.createDirectories() + alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + agent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent", isDirectory: true)) + } + + override func tearDown() async throws { + alive.release() + try? setImmutable(h.home.paths.configFile, false) + acls.removeGiven() + h.home.destroy() + } + + // MARK: The agent + + /// One agent run with the app alive at thermal pressure `level`. + /// Returns whether it ended the session: session.json removed, sleep + /// restored and the journal says so. + private func agentEnds(level: Int) async throws -> Bool { + try agent.setThermal(level) + let exit = try await agent.run() + XCTAssertEqual(exit, 0, logText()) + let ended = try h.store.loadSession() == nil + XCTAssertEqual(agent.calls.contains(agent.restoreCall), ended, agent.calls.joined(separator: "\n")) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, !ended) + return ended + } + + private func logText() -> String { + (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + } + + // MARK: Both sides + + /// Whether FloorRules ends the session on the app's settings at + /// `battery` percent on battery power, at critical or nominal heat. + private func appEnds(_ m: SessionManager, critical: Bool, battery: Int = 25) -> Bool { + FloorRules.evaluate(battery: .percent(battery), isCharging: false, thermal: critical ? .critical : .nominal, + lidClosed: false, lowPowerSetByUs: false, config: m.config) + .contains { if case .endSession = $0 { true } else { false } } + } + + /// Asks the app, then the agent, about the running session at `battery` + /// percent, and checks that both give `expected`. An agent that ends + /// the session ends it for good, so `expected == true` is the last + /// question about it. + private func assertBoth(_ m: SessionManager, critical: Bool, battery: Int = 25, end expected: Bool, + file: StaticString = #filePath, line: UInt = #line) async throws { + XCTAssertTrue(m.isActive, "a session runs", file: file, line: line) + XCTAssertEqual(appEnds(m, critical: critical, battery: battery), expected, "the app on \(m.config.agentCutoffs.description)", file: file, line: line) + try self.agent.setBattery(battery) + let agent = try await agentEnds(level: critical ? 3 : 0) + XCTAssertEqual(agent, expected, "the agent on config.json \(String(describing: try? h.store.loadConfig()?.agentCutoffs.description)): \(logText())", file: file, line: line) + if agent { + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive, file: file, line: line) + } + } + + private func startWith(endFloor: Int, thermalRules: Bool = true, _ edit: (inout Config) -> Void = { _ in }) async throws -> SessionManager { + var c = Config() + c.setEndFloor(endFloor) + c.thermalRules = thermalRules + edit(&c) + try h.store.saveConfig(c) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + return m + } + + // MARK: A deleted config.json + + /// config.json deleted during a session on a 30% floor: the agent would + /// enforce its own 10%. The next tick writes the settings in use back, + /// and both end at 25%. + func testADeletedConfigIsWrittenBackAndBothEndAtTheSelectedFloor() async throws { + let m = try await startWith(endFloor: 30) + try FileManager.default.removeItem(at: h.home.paths.configFile) + + await m.noticeConfigFileChange() + + XCTAssertEqual(try h.store.loadConfig(), m.config) + XCTAssertEqual(m.config.endFloor, 30) + try await assertBoth(m, critical: false, end: true) + } + + /// The same deletion where the settings cannot be written back, as on a + /// full disk: the agent's 10% would differ from the app's 30%, so the + /// tick's transaction ends the session and Start is refused until the + /// file is written. Then both end at 25% again. + func testADeletedConfigThatCannotBeWrittenBackStopsSessionsOnAFloorTheAgentLacks() async throws { + let m = try await startWith(endFloor: 30) + let dir = h.home.paths.appSupport + try FileManager.default.removeItem(at: h.home.paths.configFile) + try acls.denyNewFiles(in: dir) + + await m.noticeConfigFileChange() + + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + let why = try XCTUnwrap(m.rejectedConfigFile) + XCTAssertTrue(why.hasSuffix("Free some disk space or make \(dir.path) writable."), why) + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, 1) + + try acls.removeAll(dir) + await m.start(duration: 3600) + + XCTAssertNil(m.rejectedConfigFile) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 30) + try await assertBoth(m, critical: false, end: true) + } + + /// When the settings in use carry the agent's own defaults (10%, + /// thermal rules on), a missing file that cannot be written changes + /// nothing either side enforces: the session goes on, the failure is + /// logged once, and the tick looks again only after the retry delay. + func testADeletedConfigThatCannotBeWrittenBackKeepsASessionOnTheAgentsDefaults() async throws { + let m = try await startWith(endFloor: 10) + let dir = h.home.paths.appSupport + try FileManager.default.removeItem(at: h.home.paths.configFile) + try acls.denyNewFiles(in: dir) + + await m.noticeConfigFileChange() + await m.noticeConfigFileChange() + + XCTAssertTrue(m.isActive) + XCTAssertNil(m.rejectedConfigFile) + let failure = "could not write the settings in use to the missing config.json" + XCTAssertEqual(logText().components(separatedBy: failure).count - 1, 1, logText()) + try acls.removeAll(dir) + h.clock.advance(59) + await m.noticeConfigFileChange() + XCTAssertNil(try h.store.loadConfig(), "inside the retry delay") + + try await assertBoth(m, critical: false, end: false) + + h.clock.advance(1) + await m.noticeConfigFileChange() + XCTAssertEqual(try h.store.loadConfig(), m.config) + try await assertBoth(m, critical: true, end: true) + } + + // MARK: A Settings change that cannot be saved + + /// Raising the end floor from 10% to 30% while config.json cannot be + /// written changes neither side: Settings says why, and both keep the + /// session at 25%. Once the file can be written, the same change ends + /// it on both sides. + func testAnEndFloorChangeThatCannotBeSavedChangesNeitherSide() async throws { + let m = try await startWith(endFloor: 10) + let file = h.home.paths.configFile + try setImmutable(file, true) + + XCTAssertFalse(m.updateConfig { $0.setEndFloor(30) }) + + XCTAssertEqual(m.config.endFloor, 10) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 10) + let error = try XCTUnwrap(m.configSaveError) + XCTAssertTrue(error.hasPrefix("Could not save the change to config.json ("), error) + XCTAssertTrue(error.hasSuffix("so both stay at end floor 10%, thermal rules on."), error) + try await assertBoth(m, critical: false, end: false) + + try setImmutable(file, false) + XCTAssertTrue(m.updateConfig { $0.setEndFloor(30) }) + + XCTAssertNil(m.configSaveError) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 30) + try await assertBoth(m, critical: false, end: true) + } + + /// Turning the thermal rules on while config.json cannot be written: + /// at critical heat both keep the session; once saved, both end it. + func testAThermalRuleChangeThatCannotBeSavedChangesNeitherSide() async throws { + let m = try await startWith(endFloor: 0, thermalRules: false) + let file = h.home.paths.configFile + try setImmutable(file, true) + + XCTAssertFalse(m.updateConfig { $0.thermalRules = true }) + + XCTAssertFalse(m.config.thermalRules) + XCTAssertEqual(try h.store.loadConfig()?.thermalRules, false) + XCTAssertNotNil(m.configSaveError) + try await assertBoth(m, critical: true, end: false) + + try setImmutable(file, false) + XCTAssertTrue(m.updateConfig { $0.thermalRules = true }) + + XCTAssertNil(m.configSaveError) + try await assertBoth(m, critical: true, end: true) + } + + /// A change to any other setting is not held back by a write that + /// fails: it applies at once, as before, and Settings shows no cutoff + /// error. + func testAnotherSettingChangesThoughItCannotBeSaved() async throws { + let m = try await startWith(endFloor: 10) + try setImmutable(h.home.paths.configFile, true) + + XCTAssertFalse(m.updateConfig { $0.muteOnLidClose.toggle() }) + + XCTAssertEqual(m.config.muteOnLidClose, !Config().muteOnLidClose) + XCTAssertNil(m.configSaveError) + XCTAssertEqual(try h.store.loadConfig()?.muteOnLidClose, Config().muteOnLidClose) + } + + /// A cutoff changed in memory without its write (code that bypasses + /// `updateConfig`) lasts only to the next transaction, which takes the + /// file's value back: the app never runs on a cutoff the agent lacks. + func testACutoffChangedOnlyInMemoryIsTakenBackFromTheFile() async throws { + let m = try await startWith(endFloor: 10) + try setImmutable(h.home.paths.configFile, true) + m.config.setEndFloor(30) + + await m.extend(by: 60) + + XCTAssertEqual(m.config.endFloor, 10) + XCTAssertTrue(logText().contains("config.json has end floor 10%, thermal rules on, the app had end floor 30%, thermal rules on"), logText()) + try await assertBoth(m, critical: false, end: false) + } + + // MARK: config.json repaired or edited by hand + + /// A file the app rejects and cannot move ends the session. Repaired by + /// hand into a valid file with a 0% floor and no thermal rule, it is + /// what the agent enforces, so the next Start takes those two values + /// and nothing else from it: both keep the session at 25% and critical + /// heat. A later hand edit to a 50% floor reaches the app at the next + /// tick, and both end. + func testAConfigRepairedByHandIsWhatBothEnforce() async throws { + let m = try await startWith(endFloor: 30) { $0.muteOnLidClose = !Config().muteOnLidClose } + let file = h.home.paths.configFile + try Data(#"{"endFloor": 0, "thermalRules": false, "freezeList": 42}"#.utf8).write(to: file) + try setImmutable(file, true) + await m.extend(by: 60) + XCTAssertFalse(m.isActive) + XCTAssertNotNil(m.rejectedConfigFile) + + try setImmutable(file, false) + var repaired = Config() + repaired.endFloor = 0 + repaired.thermalRules = false + try h.store.saveConfig(repaired) + await m.start(duration: 3600) + + XCTAssertNil(m.rejectedConfigFile) + XCTAssertEqual(m.config.agentCutoffs, AgentCutoffs(endFloor: 0, thermalRules: false)) + XCTAssertEqual(m.config.muteOnLidClose, !Config().muteOnLidClose, "only the cutoffs come from the file") + XCTAssertEqual(try h.store.loadConfig(), repaired, "the file is not rewritten") + try await assertBoth(m, critical: true, end: false) + + var edited = repaired + edited.endFloor = 50 + try h.store.saveConfig(edited) + await m.noticeConfigFileChange() + + XCTAssertEqual(m.config.endFloor, 50) + XCTAssertEqual(m.config.lowPowerFloor, 55, "raised above the new end floor") + try await assertBoth(m, critical: false, end: true) + } + + // MARK: config.json as the app's decoder reads it + + /// config.json holding `text`, which cannot be written, so the app + /// keeps it as written. The app's decoder takes `expected` from it, a + /// session started on it takes the same, and at `battery` percent and + /// critical heat or not the app and the agent both end it or both keep + /// it. Ends a session the agent kept, so a test can run several texts. + private func assertBothRead(_ text: String, as expected: AgentCutoffs, battery: Int = 25, critical: Bool = false, end: Bool, + file: StaticString = #filePath, line: UInt = #line) async throws { + try agent.clearCalls() + let bytes = Data(text.utf8) + try bytes.write(to: h.home.paths.configFile) + XCTAssertEqual(try h.store.loadConfig()?.agentCutoffs, expected, "the app's decoder on \(text)", file: file, line: line) + try setImmutable(h.home.paths.configFile, true) + defer { try? setImmutable(h.home.paths.configFile, false) } + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, text, file: file, line: line) + XCTAssertNil(m.rejectedConfigFile, text, file: file, line: line) + XCTAssertEqual(m.config.agentCutoffs, expected, "the app adopts the file's cutoffs: \(text)", file: file, line: line) + XCTAssertEqual(try Data(contentsOf: h.home.paths.configFile), bytes, file: file, line: line) + + XCTAssertEqual(appEnds(m, critical: critical, battery: battery), end, "the app on \(text)", file: file, line: line) + try agent.setBattery(battery) + let ended = try await agentEnds(level: critical ? 3 : 0) + XCTAssertEqual(ended, end, "the agent on \(text): \(logText())", file: file, line: line) + XCTAssertFalse(logText().contains("enforcing the strictest"), logText(), file: file, line: line) + if ended { + await m.noticeAgentEnd() + } else { + await m.end(reason: .user) + } + XCTAssertFalse(m.isActive, file: file, line: line) + } + + /// The round-22 review's cases: Swift's JSONDecoder takes the first of + /// two endFloor keys, also when one is written with an escape, where a + /// property-list reader takes the last. The agent gets the app's + /// answer, in either order. + func testDuplicateAndEscapedEndFloorKeysAreReadAsTheAppReadsThem() async throws { + let ninetyFive = AgentCutoffs(endFloor: 95, thermalRules: false) + let off = AgentCutoffs(endFloor: 0, thermalRules: false) + try await assertBothRead(#"{"endFloor":95,"endFloor":0,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, as: ninetyFive, end: true) + try await assertBothRead(#"{"end\u0046loor":95,"endFloor":0,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, as: ninetyFive, end: true) + try await assertBothRead(#"{"endFloor":95,"end\u0046loor":0,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, as: ninetyFive, end: true) + try await assertBothRead(#"{"endFloor":0,"endFloor":95,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, as: off, battery: 5, end: false) + try await assertBothRead(#"{"end\u0046loor":0,"endFloor":95,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, as: off, battery: 5, end: false) + } + + /// The same for two thermalRules keys: at critical heat both end the + /// session when the first says true and keep it when it says false. + func testDuplicateThermalRulesKeysAreReadAsTheAppReadsThem() async throws { + try await assertBothRead(#"{"endFloor":0,"thermalRules":true,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 0, thermalRules: true), critical: true, end: true) + try await assertBothRead(#"{"endFloor":0,"thermal\u0052ules":true,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 0, thermalRules: true), critical: true, end: true) + try await assertBothRead(#"{"endFloor":0,"thermalRules":false,"thermalRules":true,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 0, thermalRules: false), critical: true, end: false) + } + + /// Controls: ordinary files, the zero and off settings, and a file + /// with neither key, which is the defaults. + func testOrdinaryCutoffsAreReadAsWritten() async throws { + try await assertBothRead(#"{"endFloor":95,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 95, thermalRules: false), end: true) + try await assertBothRead(#"{"endFloor":0,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 0, thermalRules: false), battery: 5, critical: true, end: false) + try await assertBothRead(#"{"endFloor":10,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 10, thermalRules: false), battery: 9, end: true) + try await assertBothRead(#"{"endFloor":10,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 10, thermalRules: false), battery: 10, critical: true, end: false) + try await assertBothRead(#"{"endFloor":0,"thermalRules":true,"configVersion":2,"lidCloseDefaultsApplied":true}"#, + as: AgentCutoffs(endFloor: 0, thermalRules: true), battery: 5, critical: true, end: true) + try await assertBothRead("{}", as: Config.agentDefaultCutoffs, battery: 9, end: true) + } + + /// The round-22 review's cases: during a session on the default 10% + /// with the thermal rules off, config.json is replaced by one holding + /// endFloor 0 and an error in another field, so the app's decoder + /// rejects the whole file. An app that has stopped answering keeps 10%, + /// so at 5% the agent must end the session on its defaults, not read + /// the floor from the rejected file as off. The same run on the app's + /// settings is the control. + func testAFileRejectedForAnotherFieldKeepsTheAgentOnItsDefaults() async throws { + var c = Config() + c.thermalRules = false + try h.store.saveConfig(c) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + try agent.setBattery(5) + for field in [#""lowPowerFloor":"bad""#, #""presets":["bad"]"#, #""lowPowerFloor":-9223372036854775809"#] { + try session.write(to: h.home.paths.sessionFile) + try journal.write(to: h.home.paths.stateFile) + let text = #"{"endFloor":0,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true,"# + field + "}" + try Data(text.utf8).write(to: h.home.paths.configFile) + XCTAssertThrowsError(try h.store.loadConfig(), text) + XCTAssertTrue(appEnds(m, critical: false, battery: 5), "the app on \(m.config.agentCutoffs.description)") + + let ended = try await agentEnds(level: 0) + XCTAssertTrue(ended, "the agent keeps a session at 5% on \(text): \(logText())") + XCTAssertTrue(logText().contains("below the 10% end floor"), logText()) + } + XCTAssertFalse(logText().contains("enforcing the strictest"), logText()) + + try session.write(to: h.home.paths.sessionFile) + try journal.write(to: h.home.paths.stateFile) + try h.store.saveConfig(c) + let control = try await agentEnds(level: 0) + XCTAssertTrue(control, "the agent on the app's settings: \(logText())") + } + + /// Greptile 4215544412: a hand edit to endFloor 1e-400 or + /// 4.9999999999999999, which the decoder rounds to 0 and 5. The app + /// adopts the rounded floor at its next tick and leaves the file as + /// written; the agent enforces the same floor from the same bytes, and + /// again after the app writes the file in its own form. + func testARoundedEndFloorIsTheSameOnBothSides() async throws { + let m = try await startWith(endFloor: 10, thermalRules: false) + for (token, floor) in [("1e-400", 0), ("4.9999999999999999", 5)] { + let raw = Data(#"{"endFloor":\#(token),"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#.utf8) + try raw.write(to: h.home.paths.configFile) + await m.noticeConfigFileChange() + XCTAssertNil(m.rejectedConfigFile) + XCTAssertEqual(m.config.agentCutoffs, AgentCutoffs(endFloor: floor, thermalRules: false), token) + XCTAssertEqual(try Data(contentsOf: h.home.paths.configFile), raw, "the hand edit stays as written") + + try await assertBoth(m, critical: false, battery: max(floor, 1), end: false) + try h.store.saveConfig(m.config) + XCTAssertNotEqual(try Data(contentsOf: h.home.paths.configFile), raw) + try await assertBoth(m, critical: false, battery: max(floor, 1), end: false) + } + try await assertBoth(m, critical: false, battery: 4, end: true) + } + + // MARK: The cutoffs recorded for the session + + /// A config.json the app's decoder rejects for a field other than the + /// cutoffs (freezeList 42). + private let rejectedConfig = Data(#"{"endFloor":30,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true,"freezeList":42}"#.utf8) + + private func recorded() throws -> AgentCutoffs? { try h.store.loadState()?.sessionCutoffs } + + /// The round-24 review's case: a session on a 30% floor with the thermal + /// rules off, and an app that has stopped answering (no transaction + /// runs; the alive lock stays held). config.json is then rejected as a + /// whole, or deleted. The agent enforces the cutoffs the app recorded + /// for the session in state.json, so at 20% both end it, and at 40%, + /// at critical heat too, both keep it: neither the agent's defaults + /// (10%, rule on) nor the strictest (95%, rule on) apply. The agent's + /// end leaves the record; the app's end of the session clears it. Each + /// row runs on a home of its own with this session's files, several at + /// a time; the last row then runs again on this test's home, so that + /// the app sees the agent's end. + func testAHungSessionKeepsItsRecordedCutoffsWhileConfigIsRejectedOrMissing() async throws { + let m = try await startWith(endFloor: 30, thermalRules: false) + let cutoffs = AgentCutoffs(endFloor: 30, thermalRules: false) + XCTAssertEqual(try recorded(), cutoffs, "Start records them") + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + let breaks: [(name: String, config: Data?)] = [("rejected", rejectedConfig), ("missing", nil)] + var rows: [(label: String, end: Bool, run: SeparateRun)] = [] + for (name, config) in breaks { + for (battery, critical, end) in [(40, true, false), (40, false, false), (31, false, false), (29, false, true), (20, false, true)] { + XCTAssertEqual(appEnds(m, critical: critical, battery: battery), end, "the app at \(battery)%") + let run = try SeparateRun(in: h, name: "\(name)-\(battery)-\(critical)", config: config ?? Data(), battery: battery, + level: critical ? 3 : 0, prepare: { _ in }) { paths in + try session.write(to: paths.sessionFile) + try journal.write(to: paths.stateFile) + try config?.write(to: paths.configFile) + } + rows.append(("\(name) config.json at \(battery)%, critical \(critical)", end, run)) + } + } + + let results = try await SeparateRun.runAll(rows.map(\.run)) + + var logs: [String] = [] + for (row, result) in zip(rows, results) { + let log = try row.run.check(result, row.label) + XCTAssertEqual(result.ended, row.end, "\(row.label): \(log)") + XCTAssertEqual(try Store(paths: row.run.paths).loadState()?.sessionCutoffs, cutoffs, "the agent keeps the record: \(row.label)") + logs.append(log) + } + XCTAssertTrue(logs.contains { $0.contains("below the 30% end floor") }, logs.joined(separator: "\n")) + XCTAssertFalse(logs.contains { $0.contains("enforcing the strictest") }, logs.joined(separator: "\n")) + + try FileManager.default.removeItem(at: h.home.paths.configFile) + try agent.setBattery(20) + let ended = try await agentEnds(level: 0) + XCTAssertTrue(ended, "missing config.json at 20% here: \(logText())") + XCTAssertEqual(try recorded(), cutoffs, "the agent keeps the record") + XCTAssertTrue(logText().contains("below the 30% end floor"), logText()) + XCTAssertFalse(logText().contains("enforcing the strictest"), logText()) + + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertNil(try recorded(), "the app clears it once session.json is gone") + } + + /// The journal's record as the agent reads it through the app's binary, + /// with config.json rejected: escaped keys as the app's decoder takes + /// them; none (a session an older build started) and no state.json at + /// all are the defaults; a value the app does not write, which the app + /// reads as none and records its own over, is the defaults too, with + /// the reason logged. A record found twice, which the app never writes, + /// is read as the app reads it (the first copy): the binary decodes the + /// whole journal. Each journal runs on a home of its own with this + /// session and config.json rejected, several at a time; no state.json + /// at all runs here. + func testTheAgentReadsTheRecordAsTheAppDoes() async throws { + _ = try await startWith(endFloor: 30, thermalRules: false) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let base = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false"# + // The line for a record the app reads as none, naming the files of + // the home the run reads. + func foreign(_ paths: Paths) -> String { + "\(paths.configFile.path) is rejected by the app; the cutoffs recorded for the session in \(paths.stateFile.path) are a value the app does not write, which it reads as none, so the app's defaults apply, a 10% end floor and thermal rules on" + } + // `log`: a line the run writes, none when empty, `foreign` when nil. + let cases: [(value: String, battery: Int, end: Bool, log: String?)] = [ + (#","sessionCutoffs":"30 false""#, 20, true, "below the 30% end floor"), + (#","sessionCutoffs":"30 false""#, 40, false, ""), + (#","session\u0043utoffs":"30 false""#, 20, true, "below the 30% end floor"), + (#","session\u0043utoffs":"30 false""#, 40, false, ""), + ("", 20, false, ""), + ("", 9, true, "below the 10% end floor"), + (#","sessionCutoffs":null"#, 9, true, "below the 10% end floor"), + (#","sessionCutoffs":"96 false""#, 40, false, nil), + (#","sessionCutoffs":"96 false""#, 9, true, nil), + (#","sessionCutoffs":30"#, 40, false, nil), + (#","sessionCutoffs":30"#, 9, true, nil), + (#","sessionCutoffs":"30 off""#, 40, false, nil), + (#","sessionCutoffs":"30 off""#, 9, true, nil), + ] + let twice = [#","sessionCutoffs":"30 false","sessionCutoffs":"0 false""#, #","sessionCutoffs":"0 false","sessionCutoffs":"30 false""#, + #","session\u0043utoffs":"30 false","sessionCutoffs":"0 false""#] + func run(_ name: String, _ text: String, battery: Int) throws -> SeparateRun { + try SeparateRun(in: h, name: name, config: rejectedConfig, battery: battery, level: 0, prepare: { _ in }) { paths in + try session.write(to: paths.sessionFile) + try Data(text.utf8).write(to: paths.stateFile) + try self.rejectedConfig.write(to: paths.configFile) + } + } + var runs: [SeparateRun] = [] + for (i, c) in cases.enumerated() { + runs.append(try run("record\(i)", base + c.value + "}", battery: c.battery)) + } + var appReads: [AgentCutoffs] = [] + for (i, value) in twice.enumerated() { + let text = base + value + "}" + let r = try run("twice\(i)", text, battery: 5) + appReads.append(try XCTUnwrap(try Store(paths: r.paths).loadState()?.sessionCutoffs, "the app reads \(text)")) + runs.append(r) + } + + let results = try await SeparateRun.runAll(runs) + + for (i, c) in cases.enumerated() { + let (r, result) = (runs[i], results[i]) + let text = base + c.value + "}" + let log = try r.check(result, "\(text) at \(c.battery)%") + XCTAssertEqual(result.ended, c.end, "\(text) at \(c.battery)%: \(log)") + let line = c.log ?? foreign(r.paths) + if !line.isEmpty { + XCTAssertTrue(log.contains(line), "\(text): \(log)") + } + XCTAssertFalse(log.contains("enforcing the strictest"), "\(text): \(log)") + } + for (j, value) in twice.enumerated() { + let (r, result, appRead) = (runs[cases.count + j], results[cases.count + j], appReads[j]) + let text = base + value + "}" + let log = try r.check(result, text) + XCTAssertEqual(result.ended, 5 < appRead.endFloor, "\(text), which the app reads as \(appRead.description): \(log)") + if result.ended { + XCTAssertTrue(log.contains("below the \(appRead.endFloor)% end floor"), log) + } else { + XCTAssertEqual(try Data(contentsOf: r.paths.stateFile), Data(text.utf8), "kept as it is: \(text)") + } + XCTAssertFalse(log.contains("enforcing the strictest"), log) + } + + try session.write(to: h.home.paths.sessionFile) + try? FileManager.default.removeItem(at: h.home.paths.stateFile) + try rejectedConfig.write(to: h.home.paths.configFile) + try agent.setBattery(20) + let exit = try await agent.run() + XCTAssertEqual(exit, 0, logText()) + XCTAssertNotNil(try h.store.loadSession(), "no state.json: the defaults keep the session at 20%: \(logText())") + try agent.setBattery(9) + _ = try await agent.run() + XCTAssertNil(try h.store.loadSession(), "and end it at 9%: \(logText())") + } + + /// A Settings change during a session is recorded for it in the journal + /// before it takes effect, so a hung app's session keeps it. A change + /// between sessions records nothing; the next Start records its own. + func testACutoffChangeIsRecordedForTheSessionBeforeItTakesEffect() async throws { + let m = try await startWith(endFloor: 10) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 10, thermalRules: true)) + + XCTAssertTrue(m.updateConfig { + $0.setEndFloor(30) + $0.thermalRules = false + }) + + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: false)) + XCTAssertEqual(try h.store.loadConfig()?.agentCutoffs, AgentCutoffs(endFloor: 30, thermalRules: false)) + XCTAssertTrue(m.updateConfig { $0.muteOnLidClose.toggle() }, "another setting records nothing") + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: false)) + XCTAssertTrue(m.updateConfig { $0.setEndFloor(200) }, "Settings clamps the floor") + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 95, thermalRules: false), "the clamped floor in use is recorded") + XCTAssertTrue(m.updateConfig { $0.setEndFloor(30) }) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + try rejectedConfig.write(to: h.home.paths.configFile) + XCTAssertFalse(appEnds(m, critical: true, battery: 40)) + try agent.setBattery(40) + let keptHot = try await agentEnds(level: 3) + XCTAssertFalse(keptHot, "the rule turned off is the one recorded: \(logText())") + try session.write(to: h.home.paths.sessionFile) + try journal.write(to: h.home.paths.stateFile) + try rejectedConfig.write(to: h.home.paths.configFile) + XCTAssertTrue(appEnds(m, critical: false, battery: 20)) + try agent.setBattery(20) + let ended = try await agentEnds(level: 0) + XCTAssertTrue(ended, logText()) + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertNil(try recorded()) + + try h.store.saveConfig(m.config) + XCTAssertTrue(m.updateConfig { $0.setEndFloor(50) }) + XCTAssertNil(try recorded(), "no session, nothing recorded") + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 50, thermalRules: false), "the next Start records its own") + await m.end(reason: .user) + XCTAssertNil(try recorded(), "an ordinary end clears it") + } + + /// A cutoff change whose record cannot be written (state.json + /// immutable, or the recovery lock busy with an agent run) changes + /// neither side and says why; a retry once it can be written applies + /// it. A change whose config.json write fails after the record was + /// written puts the record back, so the agent keeps the old cutoffs + /// too, also once config.json is rejected. + func testACutoffChangeThatCannotBeRecordedChangesNeitherSide() async throws { + let m = try await startWith(endFloor: 10) + let before = AgentCutoffs(endFloor: 10, thermalRules: true) + let state = h.home.paths.stateFile + try setImmutable(state, true) + + XCTAssertFalse(m.updateConfig { $0.setEndFloor(30) }) + + try setImmutable(state, false) + XCTAssertEqual(m.config.agentCutoffs, before) + XCTAssertEqual(try h.store.loadConfig()?.agentCutoffs, before, "config.json is not written") + XCTAssertEqual(try recorded(), before) + let error = try XCTUnwrap(m.configSaveError) + XCTAssertTrue(error.hasPrefix("Could not record the change for the session in state.json ("), error) + XCTAssertTrue(error.hasSuffix("so both stay at end floor 10%, thermal rules on."), error) + + let lock = try XCTUnwrap(try RecoveryLock(url: h.home.paths.recoveryLock).tryAcquire()) + XCTAssertFalse(m.updateConfig { $0.setEndFloor(30) }) + lock.release() + XCTAssertEqual(m.config.agentCutoffs, before) + XCTAssertEqual(try recorded(), before) + XCTAssertTrue(try XCTUnwrap(m.configSaveError).contains("the recovery lock is busy")) + + XCTAssertTrue(m.updateConfig { $0.setEndFloor(30) }, "the retry") + XCTAssertNil(m.configSaveError) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: true)) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, 30) + + try setImmutable(h.home.paths.configFile, true) + XCTAssertFalse(m.updateConfig { $0.setEndFloor(50) }) + try setImmutable(h.home.paths.configFile, false) + XCTAssertEqual(m.config.endFloor, 30) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: true), "put back") + XCTAssertTrue(try XCTUnwrap(m.configSaveError).hasPrefix("Could not save the change to config.json (")) + + try rejectedConfig.write(to: h.home.paths.configFile) + XCTAssertFalse(appEnds(m, critical: false, battery: 40)) + try agent.setBattery(40) + let kept = try await agentEnds(level: 0) + XCTAssertFalse(kept, "the agent is on 30%, not the 50% that was refused: \(logText())") + } + + /// Greptile 4219151883: a cutoff change whose config.json write fails + /// after the record was written, and whose record then cannot be put + /// back either (state.json made immutable between the two writes), + /// leaves a journal recording cutoffs config.json does not carry, + /// looser (30% to 10%) or stricter (10% to 30%) than the app's. Left + /// running, a hung app's session would then be kept or ended by the + /// agent against the app, once config.json is rejected (the replay + /// below, on a copy of the disk with session.json put back). The + /// session ends on disk before the lock is released instead: + /// session.json is removed, or, when it is immutable too, recorded as + /// ended in ended-session.json. Copies of the disk taken at that moment + /// show that the agent, with the app hung, restores sleep, and that a + /// relaunch resumes nothing. This process then ends the session as one + /// whose cutoffs cannot be recorded, and resumes nothing either. The + /// controls: with the record put back, the session runs on, and the + /// agent enforces the old cutoffs at 20% (ends on 30%, keeps on 10%). + func testACutoffChangeWhoseRecordCannotBePutBackEndsTheSession() async throws { + let directions: [(from: AgentCutoffs, to: Int)] = [ + (AgentCutoffs(endFloor: 30, thermalRules: false), 10), + (AgentCutoffs(endFloor: 10, thermalRules: true), 30), + ] + let session = h.home.paths.sessionFile, state = h.home.paths.stateFile, config = h.home.paths.configFile + for (from, to) in directions { + let changed = AgentCutoffs(endFloor: to, thermalRules: from.thermalRules) + + let control = try await startWith(endFloor: from.endFloor, thermalRules: from.thermalRules) + try setImmutable(config, true) + XCTAssertFalse(control.updateConfig { $0.setEndFloor(to) }) + try setImmutable(config, false) + XCTAssertTrue(control.isActive) + XCTAssertEqual(try recorded(), from, "put back") + XCTAssertTrue(try XCTUnwrap(control.configSaveError).hasSuffix("so both stay at \(from.description).")) + XCTAssertEqual(appEnds(control, critical: false, battery: 20), from.endFloor > 20) + let kept = try diskCopy(config: rejectedConfig) + defer { discard(kept) } + let keptEnds = try await agentEndsCopy(kept, battery: 20) + XCTAssertEqual(keptEnds, from.endFloor > 20, "\(from) put back") + await control.end(reason: .user) + + for recordOnly in [false, true] { + let label = "\(from) to \(changed), session.json \(recordOnly ? "immutable" : "removable")" + let m = try await startWith(endFloor: from.endFloor, thermalRules: from.thermalRules) + let sessionBytes = try Data(contentsOf: session) + let configBytes = try Data(contentsOf: config) + let holds = h.guardFake.calls.filter { $0 == "disablesleep 1" }.count + let restores = h.guardFake.calls.filter { $0 == "disablesleep 0" }.count + try? FileManager.default.removeItem(at: h.home.paths.logFile) + try setImmutable(config, true) + if recordOnly { try setImmutable(session, true) } + m.beforeRecordedCutoffsPutBack = { + do { try setImmutable(state, true) } catch { XCTFail("state.json not made immutable: \(error)") } + } + + XCTAssertFalse(m.updateConfig { $0.setEndFloor(to) }) + + // Nothing here awaits until the flags are cleared: the end + // in process has not run, and the disk is what the update + // left when it released the lock. + let crashed = try diskCopy(config: rejectedConfig) + let relaunched = try diskCopy(config: configBytes) + let unended = try diskCopy(config: rejectedConfig) + defer { [crashed, relaunched, unended].forEach(discard) } + try sessionBytes.write(to: unended.home.paths.sessionFile) + try? FileManager.default.removeItem(at: unended.home.paths.endedSessionFile) + if recordOnly { + XCTAssertEqual(try Data(contentsOf: session), sessionBytes, label) + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), sessionBytes, "the end is recorded: \(label)") + } else { + XCTAssertFalse(FileManager.default.fileExists(atPath: session.path), "the end: \(label)") + } + XCTAssertEqual(try recorded(), changed, "not put back: \(label)") + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true, label) + XCTAssertEqual(m.config.agentCutoffs, from, label) + XCTAssertEqual(try h.store.loadConfig()?.agentCutoffs, from, label) + XCTAssertEqual(m.pendingEnd, .cutoffsNotRecorded, label) + let error = try XCTUnwrap(m.configSaveError, label) + XCTAssertTrue(error.hasPrefix("Could not save the change to config.json ("), error) + XCTAssertTrue(error.contains(", or put back the end floor and thermal rules recorded for the session in state.json ("), error) + if recordOnly { + XCTAssertTrue(error.contains("so Insomnia ended the session: session.json could not be removed ("), error) + XCTAssertTrue(error.contains("its end is recorded, so a relaunch will not resume it."), error) + } else { + XCTAssertTrue(error.contains("so Insomnia ended the session. The settings"), error) + } + XCTAssertTrue(error.hasSuffix(" The settings stay at \(from.description)."), error) + let lock = try XCTUnwrap(try RecoveryLock(url: h.home.paths.recoveryLock).tryAcquire(), "the lock is released: \(label)") + lock.release() + try setImmutable(state, false) + try setImmutable(config, false) + if recordOnly { try setImmutable(session, false) } + m.beforeRecordedCutoffsPutBack = nil + + let replayEnds = try await agentEndsCopy(unended, battery: 20) + XCTAssertEqual(replayEnds, changed.endFloor > 20, "the replay, on the record: \(label)") + XCTAssertNotEqual(changed.endFloor > 20, appEnds(m, critical: false, battery: 20), "the app disagrees: \(label)") + + let crashedEnds = try await agentEndsCopy(crashed, battery: 20) + XCTAssertTrue(crashedEnds, "hung app: \(label)") + let log = (try? String(contentsOf: crashed.home.paths.logFile, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains(recordOnly ? "already ended (recorded in" : "no session; restoring from journal"), "\(label): \(log)") + XCTAssertFalse(log.contains("end floor"), "\(label): \(log)") + + let relaunch = relaunched.makeManager() + await relaunch.reconcile() + XCTAssertFalse(relaunch.isActive, "relaunch: \(label)") + XCTAssertNil(try relaunched.store.loadSession(), label) + XCTAssertFalse(relaunched.guardFake.calls.contains("disablesleep 1"), "\(label): \(relaunched.guardFake.calls)") + XCTAssertTrue(relaunched.guardFake.calls.contains("disablesleep 0"), "\(label): \(relaunched.guardFake.calls)") + + await waitUntil("the end in process: \(label)") { !m.isActive && m.pendingEnd == nil } + XCTAssertNil(try h.store.loadSession(), label) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path), label) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, false, label) + XCTAssertNil(try recorded(), label) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 0" }.count, restores + 1, label) + XCTAssertTrue(logText().contains("settings: could not put the session's recorded \(from.description) back in state.json ("), logText()) + XCTAssertTrue(logText().contains("session end (cutoffsNotRecorded)"), logText()) + XCTAssertFalse(logText().contains("the recovery agent ended it"), logText()) + XCTAssertTrue(h.notifier.posts.contains { $0.body.contains("could not record the session's end floor and thermal rules in state.json") }, label) + + let again = h.makeManager() + await again.reconcile() + XCTAssertFalse(again.isActive, "relaunch after the end: \(label)") + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, holds, "nothing held sleep again: \(label)") + } + } + } + + /// A home of its own holding the session, its recorded end and the + /// journal as the app's folder holds them now, with `config` as + /// config.json: what an agent run or a relaunch reads if this process + /// stops here (crashes, or hangs holding the alive lock). The app's log + /// lines stay in this home's insomnia.log. Removed with `discard`. + private func diskCopy(config: Data) throws -> Harness { + let copy = Harness() + setenv(Paths.environmentKey, h.home.root.path, 1) + try copy.home.paths.createDirectories() + for file in [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] + where FileManager.default.fileExists(atPath: file.path) { + try Data(contentsOf: file).write(to: copy.home.paths.appSupport.appendingPathComponent(file.lastPathComponent)) + } + try config.write(to: copy.home.paths.configFile) + return copy + } + + /// Removes a home `diskCopy` made, leaving INSOMNIA_HOME on this one + /// (`TempHome.destroy` would point it at the process's). + private func discard(_ copy: Harness) { + try? FileManager.default.removeItem(at: copy.home.root) + } + + /// One agent run on `copy`, on battery power at `battery`% and nominal + /// heat, with the app alive and not answering. Returns whether it ended + /// the session: session.json gone and sleep restored. + private func agentEndsCopy(_ copy: Harness, battery: Int) async throws -> Bool { + let run = try PatchedBackstop(home: copy.home.root, dir: copy.home.root.appendingPathComponent("agent", isDirectory: true)) + try run.setThermal(0) + try run.setBattery(battery) + let hung = AppAliveLock(url: copy.home.paths.appAliveFile) + XCTAssertTrue(try hung.tryAcquire()) + let exit = try await run.run() + hung.release() + let log = (try? String(contentsOf: copy.home.paths.logFile, encoding: .utf8)) ?? "" + XCTAssertEqual(exit, 0, log) + let ended = try copy.store.loadSession() == nil + XCTAssertEqual(run.calls.contains(run.restoreCall), ended, run.calls.joined(separator: "\n")) + XCTAssertEqual(try copy.store.loadState()?.sleepDisabledByUs, !ended, log) + return ended + } + + /// Polls for the effect of a task this test does not await. + private func waitUntil(_ what: String, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(5) + while !condition(), Date() < deadline { + try? await Task.sleep(for: .milliseconds(20)) + } + XCTAssertTrue(condition(), what) + } + + /// The tick records the cutoffs in use where the journal holds none + /// (a session an older build started, or one written before this + /// build), other cutoffs (a hand edit), or a value the app does not + /// write; and after the app adopts a hand edit of config.json, here a + /// floor of 200 that the decoder clamps to 95. + func testTheTickRecordsTheCutoffsInUse() async throws { + let m = try await startWith(endFloor: 30, thermalRules: false) + let cutoffs = AgentCutoffs(endFloor: 30, thermalRules: false) + let base = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false"# + for value in ["", #","sessionCutoffs":"0 true""#, #","sessionCutoffs":"96 false""#, #","sessionCutoffs":30"#] { + try Data((base + value + "}").utf8).write(to: h.home.paths.stateFile) + await m.noticeConfigFileChange() + XCTAssertTrue(m.isActive) + XCTAssertEqual(try recorded(), cutoffs, value) + } + let journal = try Data(contentsOf: h.home.paths.stateFile) + await m.noticeConfigFileChange() + XCTAssertEqual(try Data(contentsOf: h.home.paths.stateFile), journal, "a record that matches is not written again") + + try Data(#"{"endFloor":200,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#.utf8).write(to: h.home.paths.configFile) + await m.noticeConfigFileChange() + XCTAssertEqual(m.config.agentCutoffs, AgentCutoffs(endFloor: 95, thermalRules: false)) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 95, thermalRules: false)) + + try rejectedConfig.write(to: h.home.paths.configFile) + try await assertBoth(m, critical: false, battery: 94, end: true) + } + + /// A transaction that cannot record the cutoffs in use for the running + /// session (state.json immutable after a hand edit of config.json to a + /// 30% floor) ends it, as a rejected config.json does, and says why. + /// The journal cannot be cleared either, so the end's notice is the + /// incomplete restore. The next Start, once state.json takes writes, + /// records them. + func testASessionWhoseCutoffsCannotBeRecordedEnds() async throws { + let m = try await startWith(endFloor: 10) + var edited = m.config + edited.setEndFloor(30) + try h.store.saveConfig(edited) + try setImmutable(h.home.paths.stateFile, true) + + await m.noticeConfigFileChange() + + try setImmutable(h.home.paths.stateFile, false) + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("ending the session: could not record its end floor 30%, thermal rules on in state.json"), logText()) + XCTAssertTrue(logText().contains("session end (cutoffsNotRecorded)"), logText()) + + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: true)) + } + + /// A session resumed after a crash or at login records its cutoffs + /// again, also over a journal an older build wrote without them, before + /// sleep is held for it. + func testAResumedSessionRecordsItsCutoffs() async throws { + let first = try await startWith(endFloor: 30, thermalRules: false) + XCTAssertTrue(first.isActive) + var journal = try XCTUnwrap(try h.store.loadState()) + journal.sessionCutoffs = nil + try h.store.saveState(journal) + + let m = h.makeManager() + await m.reconcile() + + XCTAssertTrue(m.isActive, "resumed") + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: false)) + try rejectedConfig.write(to: h.home.paths.configFile) + try agent.setBattery(20) + let ended = try await agentEnds(level: 0) + XCTAssertTrue(ended, logText()) + } + + // MARK: When the app's binary cannot answer + + /// The ways the app's binary cannot answer: the bundle declares no + /// `--agent-cutoffs` version, the binary is missing, it answers + /// something else or does not answer in time. Each pairs the words the + /// log gives for it with what breaks a run's copy. A binary that does + /// not answer is cut off after 5 s, not 1 s: the undo commands share + /// that limit, and with eight runs at once a fake that answers at once + /// was seen to start too late for 1 s. + private var binaryBreaks: [(why: String, breakIt: (PatchedBackstop) throws -> Void)] { + [ + ("declares InsomniaAgentCutoffsVersion '', not \(AgentCutoffsCommand.version)", { try $0.withdrawAgentCutoffs() }), + ("is missing or not executable", { try FileManager.default.removeItem(at: $0.appBinary) }), + ("(exit 0, output 'cutoffs 96 false')", { try $0.replaceAppBinary(with: "echo 'cutoffs 96 false'") }), + ("did not answer within 5s", { + try $0.replaceAppBinary(with: "exec /bin/sleep 300") + try $0.setCommandTimeout(5) + }), + ] + } + + /// The agent reads config.json itself when the app's binary cannot + /// answer for it, and logs why: on the app's own file with a 30% end + /// floor and the thermal rules off, and no cutoffs recorded for the + /// session, it ends a session at 29% and keeps one at 31% at critical + /// heat, as the app does, where neither the defaults (10%, on) nor the + /// strictest (95%, on) would. Each answer the binary gives here is one + /// the script does not take. Each run after the control has a home of + /// its own (`SeparateRun`), so they go several at a time. + func testTheAgentReadsConfigItselfWhenTheAppBinaryCannotAnswer() async throws { + var c = Config() + c.setEndFloor(30) + c.thermalRules = false + try h.store.saveConfig(c) + let config = try Data(contentsOf: h.home.paths.configFile) + let control = try await agentEnds(atBattery: 29) + XCTAssertTrue(control, "the binary answers: \(logText())") + + // Each run's copy has its own app binary, which one message names. + let cases: [(why: (PatchedBackstop) -> String, breakIt: (PatchedBackstop) throws -> Void)] = [ + ({ _ in "declares InsomniaAgentCutoffsVersion '', not \(AgentCutoffsCommand.version)" }, { try $0.withdrawAgentCutoffs() }), + ({ _ in "is missing or not executable" }, { try FileManager.default.removeItem(at: $0.appBinary) }), + ({ "unexpected answer from '\($0.appBinary.path) --agent-cutoffs' (exit 0, output 'cutoffs 96 false')" }, { try $0.replaceAppBinary(with: "echo 'cutoffs 96 false'") }), + ({ _ in "(exit 0, output 'rejected')" }, { try $0.replaceAppBinary(with: "echo rejected") }), + ({ _ in "(exit 65, output 'cutoffs 0 false')" }, { try $0.replaceAppBinary(with: "echo 'cutoffs 0 false'; exit 65") }), + ({ _ in "(exit 1, output '')" }, { try $0.replaceAppBinary(with: "exit 1") }), + ({ _ in "did not answer within 5s" }, { + try $0.replaceAppBinary(with: "exec /bin/sleep 300") + try $0.setCommandTimeout(5) + }), + ] + var runs: [(run: SeparateRun, why: String, ends: Bool, what: String)] = [] + for (i, (why, breakIt)) in cases.enumerated() { + for (battery, level, ends, what) in [(29, 0, true, "at 29%"), (31, 3, false, "at 31%, critical: the thermal rule is off")] { + let run = try SeparateRun(in: h, name: "\(i)-\(battery)-\(level)", config: config, battery: battery, level: level, prepare: breakIt) + runs.append((run, why(run.agent), ends, what)) + } + } + + let results = try await SeparateRun.runAll(runs.map(\.run)) + + for (r, result) in zip(runs, results) { + let log = try r.run.check(result, "\(r.why) \(r.what)") + XCTAssertEqual(result.ended, r.ends, "\(r.why) \(r.what): \(log)") + XCTAssertTrue(log.contains(r.why), "\(r.why): \(log)") + XCTAssertTrue(log.contains("enforcing the file's, read here as the app's decoder reads it: a 30% end floor and thermal rules off"), log) + XCTAssertEqual(log.contains("below the 30% end floor"), r.ends, log) + XCTAssertFalse(log.contains("enforcing the strictest"), log) + } + } + + /// The agent reads the journal's record itself when the app's binary + /// cannot answer for config.json (it is not run again on the journal) + /// or for the journal: the round-24 session on a 30% floor with the + /// thermal rules off ends at 29% and is kept at 31% at critical heat. + /// With config.json rejected (read here too) or missing, the log says + /// the record was read here; with the app's own config.json, the file + /// is read here and gives the same cutoffs. A journal without a record + /// gives the defaults where config.json is missing or rejected (a + /// session an older build started: kept at 20%, ended at 9%), and the + /// file's cutoffs where it is read here. Each run has a home of its own + /// holding copies of the session's files (`SeparateRun`), so they go + /// several at a time. + func testTheAgentReadsTheRecordItselfWhenTheAppBinaryCannotAnswer() async throws { + _ = try await startWith(endFloor: 30, thermalRules: false) + XCTAssertEqual(try recorded(), AgentCutoffs(endFloor: 30, thermalRules: false)) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + let config = try Data(contentsOf: h.home.paths.configFile) + let breaks = binaryBreaks + let configs: [(name: String, bytes: Data?)] = [("read", config), ("rejected", rejectedConfig), ("missing", nil)] + var older = try Store.decodeState(journal) + older.sessionCutoffs = nil + let olderJournal = try Store.makeEncoder().encode(older) + + // A run on copies of the session, `journal` and config.json as + // `configs` names it. + func run(_ name: String, journal: Data, config: Int, battery: Int, level: Int, + prepare: (PatchedBackstop) throws -> Void) throws -> SeparateRun { + let bytes = configs[config].bytes + return try SeparateRun(in: h, name: name, config: bytes ?? Data(), battery: battery, level: level, prepare: prepare) { paths in + try session.write(to: paths.sessionFile) + try journal.write(to: paths.stateFile) + try bytes?.write(to: paths.configFile) + } + } + + var runs: [(run: SeparateRun, label: String, end: Bool, logs: [String])] = [] + let file = "enforcing the file's, read here as the app's decoder reads it: a 30% end floor and thermal rules off" + for (b, (why, breakIt)) in breaks.enumerated() { + for (c, (name, _)) in configs.enumerated() { + for (battery, critical, end) in [(31, true, false), (29, false, true)] { + let r = try run("\(b)-\(name)-\(battery)", journal: journal, config: c, battery: battery, level: critical ? 3 : 0, prepare: breakIt) + let read = "enforcing the cutoffs recorded for the session in \(r.paths.stateFile.path), read here: a 30% end floor and thermal rules off" + runs.append((r, "\(why), config.json \(name), \(battery)%, critical \(critical)", end, [why, name == "read" ? file : read])) + } + } + } + // The last break's copy, whose binary then goes too. + let gone: (PatchedBackstop) throws -> Void = { + try breaks[3].breakIt($0) + try FileManager.default.removeItem(at: $0.appBinary) + } + let defaults = "records no cutoffs for the session (an older build started it), so the app's defaults apply, a 10% end floor and thermal rules on" + for (c, battery, end, logs) in [ + (2, 20, false, [defaults]), + (2, 9, true, ["below the 10% end floor"]), + (1, 20, false, ["read here, the app rejects it: ", defaults]), + (1, 9, true, ["read here, the app rejects it: ", "below the 10% end floor"]), + (0, 31, false, [file]), + (0, 29, true, [file, "below the 30% end floor"]), + ] { + let r = try run("older-\(configs[c].name)-\(battery)", journal: olderJournal, config: c, battery: battery, level: 0, prepare: gone) + runs.append((r, "no record, config.json \(configs[c].name), \(battery)%", end, logs)) + } + + let results = try await SeparateRun.runAll(runs.map(\.run)) + + for (r, result) in zip(runs, results) { + let log = try r.run.check(result, r.label) + XCTAssertEqual(result.ended, r.end, "\(r.label): \(log)") + for line in r.logs { + XCTAssertTrue(log.contains(line), "\(r.label): \(log)") + } + XCTAssertFalse(log.contains("enforcing the strictest"), "\(r.label): \(log)") + } + } + + // MARK: Each policy, with and without the app's binary + + /// What state.json holds in a policy run (`policyRun`). + private enum JournalForm: CustomStringConvertible { + /// A running session's journal with this sessionCutoffs text, or + /// none. + case record(String?) + /// No state.json. + case missing + /// A symlink to nothing, which the app reads as no journal. + case dangling + /// A regular file this user cannot read, which the app does not + /// load. + case unreadable + + var description: String { + switch self { + case .record(let value?): "record '\(value)'" + case .record(nil): "no record" + case .missing: "no state.json" + case .dangling: "state.json a symlink to nothing" + case .unreadable: "state.json unreadable" + } + } + + /// Whether the journal records the session's sleep hold + /// (journalBase does), so an end restores sleep. With no journal + /// the agent has no hold recorded to undo. + var holdsSleep: Bool { + switch self { + case .record: true + case .missing, .dangling, .unreadable: false + } + } + } + + private static let journalBase = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false"# + + /// A run on a running session, config.json `config` (none when nil), + /// state.json as `journal` says, the battery at `battery`% on battery + /// power and thermal pressure `level`, the app's binary broken by + /// `breakIt` (or not, when nil). + private func policyRun(_ name: String, config: Data?, journal: JournalForm, battery: Int, level: Int, + breakIt: ((PatchedBackstop) throws -> Void)?) throws -> SeparateRun { + let now = h.clock.now + return try SeparateRun(in: h, name: name, config: config ?? Data(), battery: battery, level: level, prepare: { try breakIt?($0) }) { paths in + try Store(paths: paths).saveSession(Session(startedAt: now, endsAt: now.addingTimeInterval(3600))) + try config?.write(to: paths.configFile) + switch journal { + case .record(let value): + let record = value.map { #","sessionCutoffs":"\#($0)""# } ?? "" + try Data((Self.journalBase + record + "}").utf8).write(to: paths.stateFile) + case .missing: + break + case .dangling: + try FileManager.default.createSymbolicLink(at: paths.stateFile, withDestinationURL: paths.appSupport.appendingPathComponent("nothing.json")) + case .unreadable: + try Data((Self.journalBase + "}").utf8).write(to: paths.stateFile) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: paths.stateFile.path) + } + } + } + + /// One policy run made, with what it should do: `ends` true or false + /// with exit 0, or nil for a run that stops (exit 1) with nothing done. + private struct PolicyCase { + let run: SeparateRun + let label: String + let journal: JournalForm + let ends: Bool? + let logs: [String] + let record: AgentCutoffs? + } + + /// Makes `policyRun`s and remembers what each should do. A run that + /// stops on the journal (`ends` nil) stops before it asks the binary, + /// so it logs no line about the binary. + private func policyCase(_ name: String, config: Data?, journal: JournalForm, battery: Int, level: Int, + breakIt: (why: String, breakIt: (PatchedBackstop) throws -> Void)?, ends: Bool?, + logs: [String]) throws -> PolicyCase { + let run = try policyRun(name, config: config, journal: journal, battery: battery, level: level, breakIt: breakIt?.breakIt) + let record = try? Store(paths: run.paths).loadState()?.sessionCutoffs + let label = "\(name): \(journal), \(battery)%, level \(level), \(breakIt?.why ?? "the binary answers")" + let why = ends == nil ? [] : breakIt.map { [$0.why] } ?? [] + return PolicyCase(run: run, label: label, journal: journal, ends: ends, logs: why + logs, record: record ?? nil) + } + + /// Runs the cases, at most sixteen at a time, and checks each: the exit + /// status, whether session.json is gone, that sleep is restored only + /// with it and only when the journal holds the sleep hold, that the + /// record the app reads in a journal it loads is the one there before + /// the run, and the log lines named. Returns the logs. + @discardableResult + private func runPolicyCases(_ cases: [PolicyCase]) async throws -> [String] { + let results = try await SeparateRun.runAll(cases.map(\.run)) + var logs: [String] = [] + for (c, result) in zip(cases, results) { + if case .unreadable = c.journal { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: c.run.paths.stateFile.path) + } + let log = try c.run.log + logs.append(log) + if let ends = c.ends { + XCTAssertEqual(result.status, 0, "\(c.label): \(log)") + XCTAssertEqual(result.ended, ends, "\(c.label): \(log)") + XCTAssertEqual(c.run.agent.calls.contains(c.run.agent.restoreCall), ends && c.journal.holdsSleep, "\(c.label): \(c.run.agent.calls.joined(separator: "\n"))") + } else { + XCTAssertEqual(result.status, 1, "\(c.label): \(log)") + XCTAssertFalse(result.ended, "\(c.label): \(log)") + XCTAssertFalse(c.run.agent.calls.contains(c.run.agent.restoreCall), "\(c.label): \(c.run.agent.calls.joined(separator: "\n"))") + } + if case .record = c.journal { + let state = try Store(paths: c.run.paths).loadState() + XCTAssertEqual(state?.sleepDisabledByUs, c.ends != true, "\(c.label): \(log)") + XCTAssertEqual(state?.sessionCutoffs, c.record, "the record is kept: \(c.label)") + } + for line in c.logs { + XCTAssertTrue(log.contains(line), "\(c.label): no '\(line)' in \(log)") + } + } + return logs + } + + /// config.json as the app writes it or as a user edits it by hand, with + /// the end floor off, lowered, or the thermal rules off, also one over + /// 64 KiB and one with its end floor twice (the app reads the first), + /// which an agent before round 33 did not read itself: the agent ends + /// the session exactly where the app's binary says it ends, whether the + /// binary answers or the agent reads the file itself because the binary + /// is missing, of another version, gives an answer the script does not + /// take, or does not answer in time. Each pair of battery and heat is + /// one the defaults (10%, on) or the strictest (95%, on) would decide + /// otherwise. The journal's record, here other cutoffs, is not used + /// while the file can be read, and neither is a record the app does + /// not write, none, a symlink to nothing or no journal; the record is + /// kept. + func testTheAgentEnforcesEachPolicyInConfigAsTheAppsBinaryDoes() async throws { + func app(_ floor: Int, _ rules: Bool) throws -> Data { + var c = Config() + c.setEndFloor(floor) + c.thermalRules = rules + return try Store.makeEncoder().encode(c) + } + let handEdited = Data("{\n \"thermalRules\" : false,\n \"endFloor\" : 25\n}\n".utf8) + var big = Config() + big.setEndFloor(40) + big.thermalRules = false + big.hotspotSSID = String(repeating: "a", count: 70_000) + let overSixtyFourKiB = try Store.makeEncoder().encode(big) + let twice = Data(#"{"endFloor":40,"endFloor":0,"thermalRules":false}"#.utf8) + XCTAssertEqual(try Store.decodeConfig(overSixtyFourKiB).agentCutoffs, AgentCutoffs(endFloor: 40, thermalRules: false)) + XCTAssertEqual(try Store.decodeConfig(twice).agentCutoffs, AgentCutoffs(endFloor: 40, thermalRules: false)) + let policies: [(name: String, config: Data, floor: Int, rules: Bool, probes: [(battery: Int, level: Int, ends: Bool)])] = [ + ("floor off", try app(0, true), 0, true, [(5, 0, false), (50, 3, true)]), + ("lower floor", try app(5, true), 5, true, [(7, 0, false), (4, 0, true)]), + ("thermal rules off", try app(10, false), 10, false, [(50, 3, false), (9, 0, true)]), + ("repaired by the app", try app(20, false), 20, false, [(21, 3, false), (19, 0, true)]), + ("edited by hand", handEdited, 25, false, [(27, 3, false), (24, 0, true)]), + ("over 64 KiB", overSixtyFourKiB, 40, false, [(42, 3, false), (39, 0, true)]), + ("its end floor twice", twice, 40, false, [(42, 3, false), (39, 0, true)]), + ] + let breaks: [(why: String, breakIt: (PatchedBackstop) throws -> Void)?] = [nil] + binaryBreaks.map { $0 } + var cases: [PolicyCase] = [] + for (p, policy) in policies.enumerated() { + let file = "enforcing the file's, read here as the app's decoder reads it: a \(policy.floor)% end floor and thermal rules \(policy.rules ? "on" : "off")" + for (b, breakIt) in breaks.enumerated() { + for probe in policy.probes { + cases.append(try policyCase("\(p)-\(b)-\(probe.battery)-\(probe.level)", config: policy.config, journal: .record("30 true"), + battery: probe.battery, level: probe.level, breakIt: breakIt, ends: probe.ends, + logs: breakIt == nil ? [] : [file])) + } + if p == 0 { + for (j, journal) in [JournalForm.record("96 false"), .record(nil), .dangling, .missing].enumerated() { + cases.append(try policyCase("\(p)-\(b)-journal\(j)", config: policy.config, journal: journal, + battery: 5, level: 0, breakIt: breakIt, ends: false, + logs: breakIt == nil ? [] : [file])) + } + } + } + } + + let logs = try await runPolicyCases(cases) + + for (c, log) in zip(cases, logs) { + XCTAssertFalse(log.contains("enforcing the strictest"), "\(c.label): \(log)") + XCTAssertFalse(log.contains("defaults apply"), "\(c.label): \(log)") + } + } + + /// config.json the app rejects as a whole, for a field other than the + /// cutoffs (freezeList 42), for the end floor itself (a string), for + /// text cut short or for a string with an escape JSON does not have + /// (the last two an agent before round 33 could not read itself): + /// read here, the agent finds the app rejects it too, so with or + /// without the binary it enforces the cutoffs recorded for the session + /// (40%, rules off), and the app's defaults (10%, on) where there is + /// no record, a record the app does not write, a symlink to nothing or + /// no journal. A journal that is a regular file this user cannot read + /// does not load in the app either: the run stops with the session + /// kept. + func testARejectedConfigLeavesTheRecordOrTheDefaultsWithOrWithoutTheBinary() async throws { + let configs: [(name: String, bytes: Data, breaks: [(why: String, breakIt: (PatchedBackstop) throws -> Void)?])] = [ + ("rejected for another field", rejectedConfig, [nil] + binaryBreaks.map { $0 }), + ("rejected for its end floor", Data(#"{"endFloor":"30","thermalRules":false}"#.utf8), [nil, binaryBreaks[1]]), + ("cut short", Data(#"{"endFloor":40,"thermalRules":fal"#.utf8), [nil, binaryBreaks[1]]), + ("an escape JSON does not have", Data(#"{"endFloor":40,"thermalRules":false,"hotspotSSID":"a\x41"}"#.utf8), [nil, binaryBreaks[1]]), + ] + for config in configs { + XCTAssertNil(try? Store.decodeConfig(config.bytes), config.name) + } + var cases: [PolicyCase] = [] + for (n, config) in configs.enumerated() { + for (b, breakIt) in config.breaks.enumerated() { + let rejected = breakIt == nil ? "is rejected by the app" : "read here, the app rejects it: " + let defaults = "so the app's defaults apply, a 10% end floor and thermal rules on" + let rows: [(JournalForm, Int, Int, Bool?, [String])] = [ + (.record("40 false"), 50, 3, false, []), + (.record("40 false"), 39, 0, true, ["below the 40% end floor"]), + (.record("96 false"), 50, 0, false, [rejected, "a value the app does not write, which it reads as none", defaults]), + (.record("96 false"), 9, 0, true, ["below the 10% end floor"]), + (.record(nil), 50, 0, false, breakIt == nil ? [] : [rejected, "records no cutoffs for the session (an older build started it)", defaults]), + (.record(nil), 9, 0, true, ["below the 10% end floor"]), + (.dangling, 50, 0, false, [rejected, "is a symlink to nothing, which the app reads as no journal", defaults]), + (.dangling, 9, 0, true, ["below the 10% end floor"]), + (.missing, 50, 0, false, breakIt == nil ? [] : [rejected, "there is no ", defaults]), + (.missing, 9, 0, true, ["below the 10% end floor"]), + (.unreadable, 50, 0, nil, ["is kept as it is: its cutoffs are not read and it is not ended"]), + ] + for (r, row) in rows.enumerated() { + var logs = row.4 + if case .record("40 false") = row.0, breakIt != nil { + logs += [rejected, "read here: a 40% end floor and thermal rules off"] + } + cases.append(try policyCase("\(n)-\(b)-\(r)", config: config.bytes, journal: row.0, battery: row.1, level: row.2, + breakIt: breakIt, ends: row.3, logs: logs)) + } + } + } + // The text cut short and the escape JSON does not have also with the + // other ways the binary fails, on the record, as the test of configs + // read neither way ran them before round 33. + for n in [2, 3] { + for b in [0, 2, 3] { + for (battery, level, ends) in [(50, 3, false), (39, 0, true)] { + let logs = (ends ? ["below the 40% end floor"] : []) + ["read here, the app rejects it: ", "read here: a 40% end floor and thermal rules off"] + cases.append(try policyCase("\(n)-break\(b)-\(battery)", config: configs[n].bytes, journal: .record("40 false"), battery: battery, level: level, + breakIt: binaryBreaks[b], ends: ends, logs: logs)) + } + } + } + + let logs = try await runPolicyCases(cases) + + for (c, log) in zip(cases, logs) { + XCTAssertFalse(log.contains("enforcing the strictest"), "\(c.label): \(log)") + XCTAssertFalse(log.contains("enforcing the file's"), "\(c.label): \(log)") + } + } + + /// config.json the agent cannot read here: over 8 MiB, which the app + /// reads, and an end floor on which Foundation's decoder stops the app + /// (a precondition in its Decimal parse; json_decimal_reads in the + /// scripts). The app's binary answers for neither: it reads no more + /// than 8 MiB (`AgentCutoffsCommand.maxInputBytes`), the bound the + /// agent reads too, and on the second it stops as the app does. Here a + /// stand-in exits with the status of a process stopped by SIGABRT, so + /// no crash report is written. With the cutoffs recorded for the + /// session (40%, rules off, what the app enforces on the first file), + /// the agent enforces the record. Without a record it has nothing on + /// disk that says what the app enforces and enforces the strictest + /// cutoffs (95%, on): on the first file it ends at 50% a session the + /// app keeps. That is an open limit (docs/spec.md), pinned here as it + /// is, not an accepted one. A read that does not finish within + /// TEXT_READ_SECONDS takes the same path; no such text runs here, as + /// one takes minutes. + func testAConfigReadNeitherWayLeavesTheRecordOrTheStrictest() async throws { + var big = Config() + big.setEndFloor(40) + big.thermalRules = false + big.hotspotSSID = String(repeating: "a", count: AgentCutoffsCommand.maxInputBytes) + let oversized = try Store.makeEncoder().encode(big) + XCTAssertGreaterThan(oversized.count, AgentCutoffsCommand.maxInputBytes) + XCTAssertEqual(try Store.decodeConfig(oversized).agentCutoffs, AgentCutoffs(endFloor: 40, thermalRules: false)) + // Not decoded here: the decoder would stop this process. + let stopping = Data(#"{"endFloor":0.\#(String(repeating: "0", count: 126))9007199254740993e142,"thermalRules":false}"#.utf8) + let tooLarge: (why: String, breakIt: (PatchedBackstop) throws -> Void) = ("(exit 74, output 'unreadable')", { _ in }) + let stops: (why: String, breakIt: (PatchedBackstop) throws -> Void) = ("(exit 134, output '')", { try $0.replaceAppBinary(with: "exit 134") }) + let configs: [(bytes: Data, here: String, breaks: [(why: String, breakIt: (PatchedBackstop) throws -> Void)])] = [ + (oversized, "it holds more than \(AgentCutoffsCommand.maxInputBytes) bytes, which is not read here", [tooLarge] + binaryBreaks), + (stopping, "on which the app's decoder stops the app", [stops, binaryBreaks[0]]), + ] + let strictest = "the cutoffs the app enforces are not known here, so enforcing the strictest, a 95% end floor and thermal rules on" + var cases: [PolicyCase] = [] + for (n, config) in configs.enumerated() { + for (b, breakIt) in config.breaks.enumerated() { + for (battery, level, ends) in [(50, 3, false), (39, 0, true)] { + cases.append(try policyCase("\(n)-\(b)-\(battery)", config: config.bytes, journal: .record("40 false"), battery: battery, level: level, + breakIt: breakIt, ends: ends, logs: [config.here, "read here: a 40% end floor and thermal rules off"])) + } + } + for (b, breakIt) in config.breaks.prefix(2).enumerated() { + for (j, journal) in [JournalForm.record(nil), .record("96 false"), .dangling, .missing].enumerated() { + cases.append(try policyCase("\(n)-none\(b)-\(j)", config: config.bytes, journal: journal, battery: 50, level: 0, + breakIt: breakIt, ends: true, logs: [config.here, strictest, "below the 95% end floor"])) + } + } + } + + let logs = try await runPolicyCases(cases) + + for (c, log) in zip(cases, logs) { + XCTAssertEqual(log.contains("enforcing the strictest"), c.record == nil, "\(c.label): \(log)") + XCTAssertFalse(log.contains("enforcing the file's"), "\(c.label): \(log)") + XCTAssertFalse(log.contains("defaults apply"), "\(c.label): \(log)") + } + } + + /// A journal the scripts' check passes and the app's binary rejects as + /// a whole (here a stand-in that answers so) is one the app does not + /// load: the agent stops with the session and the journal kept, as for + /// any journal its own check refuses. + func testTheAgentKeepsTheSessionWhenTheAppsBinaryRejectsTheJournal() async throws { + _ = try await startWith(endFloor: 30, thermalRules: false) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + try FileManager.default.removeItem(at: h.home.paths.configFile) + try agent.replaceAppBinary(with: #"[[ "$1" == --agent-session-cutoffs ]] && { echo rejected; exit 65; }; exit 1"#) + try agent.setBattery(20) + try agent.setThermal(0) + + let exit = try await agent.run() + + XCTAssertEqual(exit, 1, logText()) + XCTAssertEqual(try Data(contentsOf: h.home.paths.sessionFile), session) + XCTAssertEqual(try Data(contentsOf: h.home.paths.stateFile), journal) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertTrue(logText().contains("the app's decoder does not load it ('\(agent.appBinary.path) --agent-session-cutoffs' answered rejected)"), logText()) + XCTAssertTrue(logText().contains("is kept as it is: its cutoffs are not read and it is not ended"), logText()) + } + + // MARK: End floors outside 0...95 + + /// endFloor as written in config.json, and the floor the app takes from + /// it, clamped to 0...95, or nil where the app rejects the file. The + /// agent asks the app's binary to decode the same bytes, so it enforces + /// exactly that floor, or its default 10% where the app rejects the + /// file. The decoder (measured on this macOS) takes an integer from + /// -2^63 through 2^63 - 1. A number with a fraction or exponent goes + /// through a double: a whole value from -2^63 + 1 through 2^63 - 513 + /// decodes, -2^63 written that way does not, and some values that are + /// not whole decode by rounding (4.9999999999999999 is 5, 1e-400 is 0) + /// while others fail the file (30.5). The texts around 2^63 are the + /// last that decode on each side and the first that do not. + private static let endFloorsWrittenAsIntegers: [(text: String, app: Int?)] = [ + ("0", 0), ("-1", 0), ("5", 5), ("10", 10), ("94", 94), ("95", 95), ("96", 95), ("200", 95), + ("999999999999999999", 95), ("1000000000000000000", 95), + ("9223372036854775806", 95), ("9223372036854775807", 95), + ("-999999999999999999", 0), ("-1000000000000000000", 0), + ("-9223372036854775807", 0), ("-9223372036854775808", 0), + ("9223372036854775808", nil), ("18446744073709551615", nil), ("99999999999999999999", nil), + ("-9223372036854775809", nil), ("-99999999999999999999", nil), + ("+5", nil), ("0x5", nil), ("05", nil), ("+30", nil), + ] + + private static let endFloorsWrittenAsFloats: [(text: String, app: Int?)] = [ + ("30.0", 30), ("3e1", 30), ("29.999999999999999999", 30), ("0.0", 0), ("-0.0", 0), + ("-5.0", 0), ("-5e0", 0), ("0.0e400", 0), + ("1e2", 95), ("1e16", 95), ("1e17", 95), ("123456789012345678.5", 95), + ("9.2e18", 95), ("-9.2e18", 0), ("9223372036854775295.0", 95), ("-9223372036854775807.0", 0), + ("9223372036854775000.0", 95), ("9.223372036854775295e18", 95), + ("-9.2233720368547758e18", 0), ("-0.9223372036854775807e19", 0), + ("9223372036854775296.0", nil), ("1e19", nil), ("-1e19", nil), + ("-9223372036854775808.0", nil), ("-9223372036854775807.5", nil), + ("-9223372036854775000.5", nil), ("-9.223372036854775808e18", nil), + ("30.5", nil), ("-0.5", nil), ("1e-1", nil), (#""30""#, nil), ("true", nil), + ("5.", nil), ("-5.", nil), (".5e1", nil), ("+5.0", nil), + ("4.9999999999999999", 5), ("1e-400", 0), ("-100000000000000000.5", 0), + ] + + /// A session on disk whose journal holds sleep, as the app leaves one, + /// and one agent run at `percent` on battery power and nominal heat. + /// Returns whether it ended the session. + private func agentEnds(atBattery percent: Int, file: StaticString = #filePath, line: UInt = #line) async throws -> Bool { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now, endsAt: now.addingTimeInterval(3600))) + var journal = RuntimeState() + journal.sleepDisabledByUs = true + try h.store.saveState(journal) + try agent.setThermal(0) + try agent.setBattery(percent) + let exit = try await agent.run() + XCTAssertEqual(exit, 0, logText(), file: file, line: line) + return try h.store.loadSession() == nil + } + + /// For each text, the floor the agent enforces on that config.json is + /// the one the app takes from it, or the agent's default 10% where the + /// app rejects the file: the agent ends a session one point below it + /// and keeps it at it (at 0%, for a floor of 0, which is off). Each of + /// those agent runs gets its own home (`SeparateRun`), so they run + /// several at a time. + private func assertTheAgentFollowsTheApp(_ table: [(text: String, app: Int?)], + file: StaticString = #filePath, line: UInt = #line) async throws { + var runs: [(run: SeparateRun, ends: Bool)] = [] + for (row, (text, expected)) in table.enumerated() { + let config = Data(#"{"endFloor": \#(text), "thermalRules": false}"#.utf8) + try config.write(to: h.home.paths.configFile) + let app = try? h.store.loadConfig()?.agentCutoffs.endFloor + XCTAssertEqual(app, expected, "the app on endFloor \(text)", file: file, line: line) + let floor = app ?? Config.agentDefaultCutoffs.endFloor + if floor > 0 { + runs.append((try SeparateRun(in: h, name: "\(row)-below", config: config, battery: floor - 1), true)) + } + runs.append((try SeparateRun(in: h, name: "\(row)-at", config: config, battery: floor), false)) + } + let ended = try await SeparateRun.runAll(runs.map(\.run)) + for ((run, ends), (status, ended)) in zip(runs, ended) { + let log = try run.log + XCTAssertEqual(status, 0, log, file: file, line: line) + XCTAssertEqual(ended, ends, "\(String(decoding: run.config, as: UTF8.self)): the agent \(ended ? "ends" : "keeps") a session at \(run.battery)%: \(log)", file: file, line: line) + } + } + + func testTheAgentEnforcesTheEndFloorTheAppTakesFromAnyInteger() async throws { + try await assertTheAgentFollowsTheApp(Self.endFloorsWrittenAsIntegers) + } + + func testTheAgentEnforcesTheEndFloorTheAppTakesFromAnyFloat() async throws { + try await assertTheAgentFollowsTheApp(Self.endFloorsWrittenAsFloats) + } + + /// The reviewer's case: config.json holds endFloor Int.max and cannot + /// be written, so the app cannot put the 95 it clamps that to in its + /// place. Start accepts the file, since both sides read it, and at 25% + /// both end the session. The same agent run on a file holding the + /// app's settings is the control. + func testAnEndFloorOfIntMaxThatCannotBeRewrittenIsNinetyFiveOnBothSides() async throws { + try await assertAnUnwritableEndFloor(Int.max, battery: 25, ends: true) + } + + /// Int.min, which the app clamps to 0 (off): at 5% both keep the + /// session, where the agent's default 10% would end it. + func testAnEndFloorOfIntMinThatCannotBeRewrittenIsOffOnBothSides() async throws { + try await assertAnUnwritableEndFloor(Int.min, battery: 5, ends: false) + } + + /// The round-21 review's case: during a session on the default 10%, + /// config.json is replaced by one holding endFloor + /// -9223372036854775809, which the app's decoder rejects. An app that has + /// stopped answering keeps 10%, so at 5% the agent must end the session + /// on its default instead of reading the floor as off. The same run on + /// the app's settings is the control. + func testARejectedEndFloorBelowIntMinDoesNotTurnTheAgentsCutoffOff() async throws { + var c = Config() + c.thermalRules = false + try h.store.saveConfig(c) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + try Data(#"{"configVersion":2,"lidCloseDefaultsApplied":true,"endFloor":-9223372036854775809,"thermalRules":false}"#.utf8) + .write(to: h.home.paths.configFile) + XCTAssertThrowsError(try h.store.loadConfig()) + XCTAssertTrue(appEnds(m, critical: false, battery: 5), "the app on \(m.config.agentCutoffs.description)") + + try agent.setBattery(5) + let exit = try await agent.run() + XCTAssertEqual(exit, 0, logText()) + XCTAssertNil(try h.store.loadSession(), "the agent keeps a session at 5% on a file the app rejects: \(logText())") + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertTrue(logText().contains("below the 10% end floor"), logText()) + + try session.write(to: h.home.paths.sessionFile) + try journal.write(to: h.home.paths.stateFile) + try h.store.saveConfig(c) + let control = try await agentEnds(atBattery: 5) + XCTAssertTrue(control, "the agent on the app's settings: \(logText())") + } + + private func assertAnUnwritableEndFloor(_ value: Int, battery: Int, ends expected: Bool, + file: StaticString = #filePath, line: UInt = #line) async throws { + var c = Config() + c.endFloor = value + c.thermalRules = false + try h.store.saveConfig(c) + let written = try Data(contentsOf: h.home.paths.configFile) + try setImmutable(h.home.paths.configFile, true) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, file: file, line: line) + XCTAssertNil(m.rejectedConfigFile, file: file, line: line) + XCTAssertEqual(try Data(contentsOf: h.home.paths.configFile), written, "the file still holds \(value)", file: file, line: line) + XCTAssertEqual(appEnds(m, critical: false, battery: battery), expected, "the app on \(m.config.agentCutoffs.description)", file: file, line: line) + + try agent.setBattery(battery) + let exit = try await agent.run() + XCTAssertEqual(exit, 0, logText(), file: file, line: line) + let ended = try h.store.loadSession() == nil + XCTAssertEqual(ended, expected, "the agent on endFloor \(value) at \(battery)%: \(logText())", file: file, line: line) + XCTAssertEqual(agent.calls.contains(agent.restoreCall), expected, agent.calls.joined(separator: "\n"), file: file, line: line) + + try setImmutable(h.home.paths.configFile, false) + try h.store.saveConfig(m.config) + XCTAssertEqual(try h.store.loadConfig()?.endFloor, m.config.agentCutoffs.endFloor, file: file, line: line) + let control = try await agentEnds(atBattery: battery, file: file, line: line) + XCTAssertEqual(control, expected, "the agent on the app's settings: \(logText())", file: file, line: line) + } +} + +/// One agent run on its own INSOMNIA_HOME inside a test's home, so that +/// runs which share nothing can go several at a time: config.json holding +/// `config`, a session on disk whose journal holds sleep, as the app leaves +/// one, the app's alive lock held, and the battery at `battery` percent on +/// battery power at thermal pressure `level` (nominal by default). The same +/// as `agentEnds(atBattery:)`, one home per run. `prepare` changes the +/// run's copy of the agent first (an app binary that cannot answer). +struct SeparateRun { + let paths: Paths + let config: Data + let battery: Int + let agent: PatchedBackstop + let alive: AppAliveLock + + @MainActor init(in h: Harness, name: String, config: Data, battery: Int, level: Int = 0, + prepare: (PatchedBackstop) throws -> Void = { _ in }) throws { + let now = h.clock.now + try self.init(in: h, name: name, config: config, battery: battery, level: level, prepare: prepare) { paths in + try config.write(to: paths.configFile) + let store = Store(paths: paths) + try store.saveSession(Session(startedAt: now, endsAt: now.addingTimeInterval(3600))) + var journal = RuntimeState() + journal.sleepDisabledByUs = true + try store.saveState(journal) + } + } + + /// A run on the files `write` puts in the run's folder (session.json, + /// state.json, config.json as a test copied them); `config` is only + /// what messages show. + @MainActor init(in h: Harness, name: String, config: Data, battery: Int, level: Int, + prepare: (PatchedBackstop) throws -> Void, write: (Paths) throws -> Void) throws { + let root = h.home.root.appendingPathComponent("runs/\(name)", isDirectory: true) + paths = Paths(root: root) + try paths.createDirectories() + self.config = config + self.battery = battery + try write(paths) + agent = try PatchedBackstop(home: root, dir: root.appendingPathComponent("agent", isDirectory: true)) + try agent.setThermal(level) + try agent.setBattery(battery) + try prepare(agent) + alive = AppAliveLock(url: paths.appAliveFile) + guard try alive.tryAcquire() else { throw CocoaError(.fileLocking) } + } + + /// What `agentEnds(level:)` checks after a run: exit 0, and sleep + /// restored and the journal cleared exactly when session.json is gone. + /// Returns the run's log. + @discardableResult + func check(_ result: (status: Int32, ended: Bool), _ label: String, + file: StaticString = #filePath, line: UInt = #line) throws -> String { + let log = try self.log + XCTAssertEqual(result.status, 0, "\(label): \(log)", file: file, line: line) + XCTAssertEqual(agent.calls.contains(agent.restoreCall), result.ended, "\(label): \(agent.calls.joined(separator: "\n"))", file: file, line: line) + XCTAssertEqual(try Store(paths: paths).loadState()?.sleepDisabledByUs, !result.ended, "\(label): \(log)", file: file, line: line) + return log + } + + /// The run's log, empty when the run wrote none. A log that is there + /// but cannot be read throws, so it never reads as an empty one. + var log: String { + get throws { + guard FileManager.default.fileExists(atPath: paths.logFile.path) else { return "" } + return String(decoding: try Data(contentsOf: paths.logFile), as: UTF8.self) + } + } + + /// Runs each agent once, at most `width` at a time (PatchedBackstop's + /// runAll), then lets go of each alive lock, also when that throws. + /// Returns, in order, each exit status and whether the run removed + /// session.json. + static func runAll(_ runs: [SeparateRun], width: Int = 16) async throws -> [(status: Int32, ended: Bool)] { + defer { runs.forEach { $0.alive.release() } } + let statuses = try await PatchedBackstop.runAll(runs.map(\.agent), width: width) + return try zip(runs, statuses).map { run, status in + (status, try Store(paths: run.paths).loadSession() == nil) + } + } +} diff --git a/Tests/InsomniaTests/DurationInputTests.swift b/Tests/InsomniaTests/DurationInputTests.swift index ddd16ebd..b29d228f 100644 --- a/Tests/InsomniaTests/DurationInputTests.swift +++ b/Tests/InsomniaTests/DurationInputTests.swift @@ -159,9 +159,19 @@ final class DurationInputTests: XCTestCase { func testFieldStrings() { XCTAssertEqual(DurationInput.Field.days.placeholder, "Days") - XCTAssertEqual(DurationInput.Field.days.help, "Up to 30 days") - XCTAssertEqual(DurationInput.Field.hours.help, "0\u{2013}23") - XCTAssertEqual(DurationInput.Field.minutes.help, "0\u{2013}59") + // Days names the configured maximum session, which is what a + // session is held to, not the pill's 30-day entry ceiling. + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: 24 * 3600), "Up to 1d per session") + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: 30 * 24 * 3600), "Up to 30d per session") + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: 36 * 3600), "Up to 1d12h per session") + // Every unit, floored to the minute: what chipLabel would round away. + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: 36 * 3600 + 30 * 60 + 59), "Up to 1d12h30m per session") + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: 24 * 3600 + 30 * 60), "Up to 1d30m per session") + XCTAssertEqual(exactLabel(for: 59), "<1m") + XCTAssertEqual(exactLabel(for: 23 * 3600 + 59 * 60), "23h59m") + XCTAssertEqual(DurationInput.Field.days.help(maxDuration: Config().maxDuration), "Up to 1d per session") + XCTAssertEqual(DurationInput.Field.hours.help(maxDuration: 24 * 3600), "0\u{2013}23") + XCTAssertEqual(DurationInput.Field.minutes.help(maxDuration: 24 * 3600), "0\u{2013}59") } // MARK: DurationParser (settings presets) diff --git a/Tests/InsomniaTests/EarlierBootLowPowerClaimTests.swift b/Tests/InsomniaTests/EarlierBootLowPowerClaimTests.swift index 8a1c4be2..043e2e2c 100644 --- a/Tests/InsomniaTests/EarlierBootLowPowerClaimTests.swift +++ b/Tests/InsomniaTests/EarlierBootLowPowerClaimTests.swift @@ -52,6 +52,9 @@ final class EarlierBootLowPowerClaimTests: XCTestCase { try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-7200), endsAt: now.addingTimeInterval(-3600))) case .valid: try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3000))) + // Boot A's sleep hold, which a restart keeps: a session whose + // hold reads 0 at launch is ended, not resumed. + h.guardFake.sleepDisabled = true } } diff --git a/Tests/InsomniaTests/JournaledSessionEndTests.swift b/Tests/InsomniaTests/JournaledSessionEndTests.swift new file mode 100644 index 00000000..b522b423 --- /dev/null +++ b/Tests/InsomniaTests/JournaledSessionEndTests.swift @@ -0,0 +1,1044 @@ +import Foundation +import XCTest +@testable import Insomnia + +/// A session ended while its session.json cannot be removed is recorded as +/// ended, and no later launch resumes it. These tests take the case where +/// ended-session.json cannot hold that record either: an unrelated record +/// there that cannot be replaced. The end then goes in the journal +/// (state.json's endedSession), before anything is undone, written by the +/// recovery agent (the real backstop.sh, tools patched to fakes) or by the +/// app. A record of one session.json never ends another. When the journal +/// cannot be written either, the record goes to a new file beside them +/// (ended-session.json.<8 letters or digits>), or in the log folder when +/// their folder takes no new file, and no launch resumes the session, +/// whatever SleepDisabled reads and whichever file is repaired. When no +/// new file can be created either (here MKTEMP fails, or both folders +/// refuse new files), the record goes in the recovery lock file +/// (LockEndRecordTests), and when that takes none, in insomnia.log +/// (LogEndRecordTests). When neither takes a record (here +/// `refuseLockRecord` and `refuseLogRecord`), the agent still restores sleep, and no launch holds +/// sleep again for that session while session.json cannot be replaced or +/// the journal cannot be written, or once pmset says sleep is not held. +@MainActor +final class JournaledSessionEndTests: XCTestCase { + var h: Harness! + var agent: PatchedBackstop! + + override func setUp() async throws { + h = Harness() + try h.home.paths.createDirectories() + agent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent", isDirectory: true)) + } + + override func tearDown() async throws { + for file in [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] { + try? setImmutable(file, false) + } + h.home.destroy() + } + + private let unrelatedRecord = Data("an end record of some other session.json".utf8) + + /// A running session, then an unrelated ended-session.json, and both + /// files made immutable: neither can be removed or replaced. + private func startThenPin() async throws -> SessionManager { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + try setImmutable(h.home.paths.sessionFile, true) + try setImmutable(h.home.paths.endedSessionFile, true) + return m + } + + private func marker() throws -> String { + try Data(contentsOf: h.home.paths.sessionFile).base64EncodedString() + } + + private func runAgent(expecting status: Int32, file: StaticString = #filePath, line: UInt = #line) async throws { + let exit = try await agent.run() + XCTAssertEqual(exit, status, logText(), file: file, line: line) + } + + private func logText() -> String { + (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + } + + private func sleepHeldAgain(since count: Int) -> Bool { + h.guardFake.calls.dropFirst(count).contains("disablesleep 1") + } + + private func pinAll() throws { + for file in [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] { + try setImmutable(file, true) + } + } + + private func unpinAll() throws { + for file in [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] { + try setImmutable(file, false) + } + } + + /// The single record aside, which must hold session.json's bytes. + private func recordAside(file: StaticString = #filePath, line: UInt = #line) throws -> URL { + let records = h.store.sessionEndRecordsAside() + XCTAssertEqual(records.count, 1, "\(records)", file: file, line: line) + let record = try XCTUnwrap(records.first, file: file, line: line) + XCTAssertEqual(try Data(contentsOf: record), try Data(contentsOf: h.home.paths.sessionFile), file: file, line: line) + return record + } + + /// The reviewer's case. The app dies (its alive lock is free), and the + /// agent ends the session but can neither remove session.json nor + /// write ended-session.json. It records the end in the journal before + /// it restores sleep, and the next launch restores instead of resuming. + /// Later agent runs end it again without the checks. Once the file can + /// be removed it goes, and the next Start removes the record before it + /// writes its own session.json. + func testAnAgentCutoffRecordedOnlyInTheJournalIsNotResumedByTheNextLaunch() async throws { + _ = try await startThenPin() + let marker = try marker() + + try await runAgent(expecting: 1) + + XCTAssertNotNil(try h.store.loadSession(), "session.json is still there") + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), unrelatedRecord) + XCTAssertFalse(h.store.sessionEndIsRecorded()) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + let journal = try XCTUnwrap(try h.store.loadState()) + XCTAssertEqual(journal.endedSession, marker) + XCTAssertFalse(journal.sleepDisabledByUs) + let atRestore = try Store.makeDecoder().decode(RuntimeState.self, from: XCTUnwrap(agent.stateAtSudo)) + XCTAssertEqual(atRestore.endedSession, marker, "recorded before sleep was restored") + XCTAssertTrue(atRestore.sleepDisabledByUs) + XCTAssertTrue(logText().contains("ending the session before its deadline"), logText()) + XCTAssertTrue(logText().contains("its end is recorded in \(h.home.paths.stateFile.path) (endedSession) instead"), logText()) + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "a session the agent ended must not come back") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("reconcile: session.json holds a session already ended (recorded in state.json); restoring, not resuming"), logText()) + XCTAssertEqual(try h.store.loadState()?.endedSession, marker, "the record stays while the file does") + + let callsBefore = agent.calls.count + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(h.home.paths.stateFile.path) (endedSession))"), logText()) + XCTAssertFalse(agent.calls.dropFirst(callsBefore).contains("pmset -g batt"), "no checks for a session recorded as ended") + + try setImmutable(h.home.paths.sessionFile, false) + try await runAgent(expecting: 0) + XCTAssertNil(try h.store.loadSession()) + + let third = h.makeManager() + await third.reconcile() + await third.start(duration: 600) + XCTAssertTrue(third.isActive) + XCTAssertNil(try h.store.loadState()?.endedSession, "a start removes the record of an earlier session") + } + + /// The app's own end in the same files: it records the end in the + /// journal before it restores anything, and both a relaunch and the + /// agent then treat the session as over. + func testAnAppEndRecordedOnlyInTheJournalIsHonouredByARelaunchAndTheAgent() async throws { + let m = try await startThenPin() + let marker = try marker() + let gate = AsyncGate() + h.guardFake.restoreGate = gate + + let end = Task { @MainActor in _ = await m.end(reason: .user) } + await gate.waitUntilStarted() + let atRestore = try XCTUnwrap(try h.store.loadState()) + XCTAssertEqual(atRestore.endedSession, marker, "recorded before sleep is restored") + XCTAssertTrue(atRestore.sleepDisabledByUs) + await gate.open() + await end.value + h.guardFake.restoreGate = nil + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertEqual(try h.store.loadState()?.endedSession, marker) + XCTAssertTrue(h.notifier.posts.contains { $0.body.contains("its end is recorded, so a relaunch will not resume it") }, "\(h.notifier.posts)") + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(h.home.paths.stateFile.path) (endedSession))"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt"), agent.calls.joined(separator: "\n")) + } + + /// While the app is running and stopped or busy, the agent ends its + /// session (here on the battery floor) and can record the end only in + /// the journal. The app's next tick sees that record and ends its side. + func testTheRunningAppNoticesAnEndRecordedInTheJournal() async throws { + var c = Config() + c.endFloor = 30 + try h.store.saveConfig(c) + let m = try await startThenPin() + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("battery"), logText()) + XCTAssertEqual(try h.store.loadState()?.endedSession, try marker()) + + XCTAssertTrue(m.isActive) + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertTrue(logText().contains("is recorded as ended in state.json (endedSession)"), logText()) + } + + /// A record of an earlier session.json (other bytes) ends nothing: the + /// agent keeps the newer session of an app that is alive and within its + /// floors, the app resumes it after a relaunch, and its tick does not + /// end it. + func testARecordOfAnEarlierSessionDoesNotEndANewerOne() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-7200), endsAt: now.addingTimeInterval(3600))) + let earlier = try marker() + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-60), endsAt: now.addingTimeInterval(3600))) + XCTAssertNotEqual(try marker(), earlier) + var journal = RuntimeState() + journal.sleepDisabledByUs = true + journal.endedSession = earlier + try h.store.saveState(journal) + h.guardFake.sleepDisabled = true // the newer session's hold + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + + let m = h.makeManager() + await m.reconcile() + XCTAssertTrue(m.isActive, logText()) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 1")) + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive) + XCTAssertEqual(try h.store.loadState()?.endedSession, earlier, "only a start or a removed session.json clears it") + } + + /// Nothing can record the end: session.json, ended-session.json and + /// state.json are all immutable, the agent cannot create a record + /// aside, and neither the recovery lock file nor insomnia.log takes + /// a record. The agent + /// restores sleep but keeps sleepDisabledByUs; the + /// relaunched app cannot write the journal, so it does not resume the + /// session either. The app's pmset still reads SleepDisabled 1 here + /// (the fake sudo above changes nothing it reads): the journal write + /// alone keeps the session from resuming. + func testACutoffThatCanRecordNothingIsNotResumedWhileTheJournalCannotBeWritten() async throws { + _ = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try agent.refuseLogRecord() + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + XCTAssertNil(try h.store.loadState()?.endedSession) + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("could not journal sleep guard"), logText()) + } + + /// An agent end that could record nothing, with all three files + /// immutable, no record aside and none in the lock file or the log: the agent + /// restores sleep, which the app's pmset then reads too, and keeps + /// sleepDisabledByUs. + private func endWithNothingRecorded() async throws { + _ = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try agent.refuseLogRecord() + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + XCTAssertNil(try h.store.loadState()?.endedSession) + XCTAssertFalse(h.store.sessionEndIsRecorded()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertTrue(logText().contains("could not remove \(h.home.paths.sessionFile.path) or record its end in \(h.home.paths.endedSessionFile.path), \(h.home.paths.stateFile.path), a new file in"), logText()) + XCTAssertEqual(h.store.lockEndRecord(), .none) + h.guardFake.sleepDisabled = false + } + + private let undoneHoldLine = "reconcile: session.json holds a session whose sleep hold was undone while Insomnia was not running" + + /// The reviewer's case: after that end, state.json alone is made + /// writable again and the app relaunches (holding the alive lock). The + /// journal says sleep is held and pmset says it is not, so the session + /// is ended, not held again, and its end is recorded in the journal + /// this time. The agent then treats it as ended, and once session.json + /// can be removed it goes. + func testACutoffThatCanRecordNothingIsNotResumedOnceOnlyTheJournalCanBeWritten() async throws { + try await endWithNothingRecorded() + let marker = try marker() + try setImmutable(h.home.paths.stateFile, false) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "a session the agent ended must not come back") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertTrue(logText().contains(undoneHoldLine), logText()) + XCTAssertNotNil(try h.store.loadSession(), "session.json is still immutable") + let journal = try XCTUnwrap(try h.store.loadState()) + XCTAssertEqual(journal.endedSession, marker) + XCTAssertFalse(journal.sleepDisabledByUs) + + let callsBefore = agent.calls.count + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(h.home.paths.stateFile.path) (endedSession))"), logText()) + XCTAssertFalse(agent.calls.dropFirst(callsBefore).contains("pmset -g batt"), "no checks for a session recorded as ended") + + try setImmutable(h.home.paths.sessionFile, false) + try await runAgent(expecting: 0) + XCTAssertNil(try h.store.loadSession()) + let third = h.makeManager() + await third.reconcile() + XCTAssertFalse(third.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + } + + /// The same end, then every file made writable again before the + /// relaunch: the session is ended and its file removed, and sleep is + /// not held again. + func testACutoffThatCanRecordNothingIsNotResumedOnceEveryFileCanBeWritten() async throws { + try await endWithNothingRecorded() + for file in [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] { + try setImmutable(file, false) + } + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "a session the agent ended must not come back") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(undoneHoldLine), logText()) + XCTAssertNil(try h.store.loadSession()) + let journal = try XCTUnwrap(try h.store.loadState()) + XCTAssertFalse(journal.isDirty) + XCTAssertNil(journal.endedSession) + } + + // MARK: ended-session.json that cannot be compared + + /// An exact ended-session.json beside a session.json that cannot be + /// read (mode 0, immutable, so it cannot be moved aside), the app + /// alive. The agent restores sleep for the session it cannot read and + /// keeps the record: cmp cannot compare the two, so it is not shown to + /// be stale. Once session.json is repaired the app launches with + /// SleepDisabled 1 and ends the session the record names instead of + /// resuming it. + func testAnExactRecordKeptWhileSessionJSONCannotBeReadEndsItOnceItCan() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + try bytes.write(to: h.home.paths.endedSessionFile) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: h.home.paths.sessionFile.path) + try setImmutable(h.home.paths.sessionFile, true) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: h.home.paths.sessionFile.path) } + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), bytes, "kept while it cannot be compared") + try await runAgent(expecting: 1) + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), bytes, "kept on the retry too") + alive.release() + + try setImmutable(h.home.paths.sessionFile, false) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: h.home.paths.sessionFile.path) + XCTAssertEqual(try Data(contentsOf: h.home.paths.sessionFile), bytes) + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("already ended (recorded in ended-session.json)"), logText()) + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path)) + } + + /// The control: an ended-session.json of an earlier session's bytes + /// ends no newer session.json. A crash relaunch resumes the session, + /// and the agent removes the stale record and checks the session as + /// usual. + func testARecordOfAnEarlierSessionEndsNoNewerOne() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + try Data(#"{"endsAt":"2001-01-01T00:00:00Z","startedAt":"2001-01-01T00:00:00Z"}"#.utf8).write(to: h.home.paths.endedSessionFile) + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertTrue(next.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before)) + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try await runAgent(expecting: 0) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path)) + } + + // MARK: The record aside + + /// The reviewer's files: session.json, an unrelated ended-session.json + /// and state.json all immutable, the folder still writable. The agent + /// (the app dead) records the end in a new file before it restores + /// sleep. The next launch restores instead of resuming, later runs end + /// it again without the checks and keep the record, and once the files + /// can be changed session.json and the record go. + func testAnAgentCutoffThatCanWriteOnlyANewFileIsRecordedAsideAndNotResumed() async throws { + _ = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + + try await runAgent(expecting: 1) + + let record = try recordAside() + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertTrue(agent.namesAtSudo.contains(record.lastPathComponent), "recorded before sleep was restored: \(agent.namesAtSudo)") + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), unrelatedRecord) + XCTAssertNil(try h.store.loadState()?.endedSession) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true, "the journal could not be written") + XCTAssertTrue(logText().contains("its end is recorded in \(record.path) instead"), logText()) + var info = stat() + XCTAssertEqual(lstat(record.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "a session the agent ended must not come back") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("reconcile: session.json holds a session already ended (recorded in \(record.lastPathComponent)); restoring, not resuming"), logText()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record], "the app's end reuses the record") + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + let callsBefore = agent.calls.count + try await runAgent(expecting: 1) + alive.release() + XCTAssertTrue(logText().contains("already ended (recorded in \(record.path))"), logText()) + XCTAssertFalse(agent.calls.dropFirst(callsBefore).contains("pmset -g batt"), "no checks for a session recorded as ended") + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record], "a record that matches stays") + + try unpinAll() + try await runAgent(expecting: 0) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [], "the record goes with the file it copies") + } + + /// The reviewer's two probes. After that end, only state.json is made + /// writable and the app relaunches holding the alive lock while + /// SleepDisabled reads 1: because the restore failed, or because + /// something else set it again. The record aside ends the session all + /// the same; neither the bit nor the journal's sleepDisabledByUs + /// resumes it. + private func endAsideThenRepairTheJournal(restoreFails: Bool) async throws { + _ = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + if restoreFails { try agent.failSudo() } + + try await runAgent(expecting: 1) + let record = try recordAside() + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + + h.guardFake.sleepDisabled = true + try setImmutable(h.home.paths.stateFile, false) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "an agent cutoff stays final after the journal is repaired") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("already ended (recorded in \(record.lastPathComponent))"), logText()) + XCTAssertNotNil(try h.store.loadSession(), "session.json is still immutable") + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record]) + + // The relaunch's own end could write the journal: now both record it. + XCTAssertEqual(try h.store.loadState()?.endedSession, try marker()) + await next.noticeAgentEnd() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + } + + func testAFailedRestoreDoesNotResumeASessionRecordedAsideOnceTheJournalIsRepaired() async throws { + try await endAsideThenRepairTheJournal(restoreFails: true) + } + + func testAnotherHoldDoesNotResumeASessionRecordedAsideOnceTheJournalIsRepaired() async throws { + try await endAsideThenRepairTheJournal(restoreFails: false) + } + + /// The same end, then every file made writable before a relaunch with + /// SleepDisabled reading 1 (the restore failed): the session ends, + /// session.json and its record go, and sleep is restored, not held. + func testAFailedRestoreDoesNotResumeASessionRecordedAsideOnceEveryFileIsRepaired() async throws { + _ = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + try agent.failSudo() + try await runAgent(expecting: 1) + _ = try recordAside() + + h.guardFake.sleepDisabled = true + try unpinAll() + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled, "the relaunch restores the hold the agent could not") + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + let journal = try XCTUnwrap(try h.store.loadState()) + XCTAssertFalse(journal.isDirty) + } + + /// The app ends its own session in the same files: it writes the + /// record aside before it restores anything, says the end is recorded, + /// and a relaunch and the agent both treat the session as over. + func testAnAppEndThatCanWriteOnlyANewFileIsHonouredByARelaunchAndTheAgent() async throws { + let m = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + let gate = AsyncGate() + h.guardFake.restoreGate = gate + + let end = Task { @MainActor in _ = await m.end(reason: .user) } + await gate.waitUntilStarted() + let record = try recordAside() + await gate.open() + await end.value + h.guardFake.restoreGate = nil + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertTrue(h.notifier.posts.contains { $0.body.contains("its end is recorded, so a relaunch will not resume it") }, "\(h.notifier.posts)") + XCTAssertTrue(logText().contains("its end is recorded in \(record.lastPathComponent)"), logText()) + + h.guardFake.sleepDisabled = true // whatever the bit reads, no resume + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(record.path))"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt"), agent.calls.joined(separator: "\n")) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record]) + } + + /// While the app is running and stopped or busy, the agent ends its + /// session on the battery floor and can record the end only aside. The + /// app's next tick sees that record and ends its side. + func testTheRunningAppNoticesAnEndRecordedAside() async throws { + var c = Config() + c.endFloor = 30 + try h.store.saveConfig(c) + let m = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("battery"), logText()) + let record = try recordAside() + + XCTAssertTrue(m.isActive) + let before = h.guardFake.calls.count + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("is recorded as ended in \(record.lastPathComponent)"), logText()) + } + + /// A record aside of an earlier session.json (other bytes) ends + /// nothing: the agent keeps the newer session of an app that is alive + /// and within its floors and removes the stale record, a relaunch + /// resumes the session past another such record, its tick does not end + /// it, and its own end removes the record with session.json. + func testARecordAsideOfAnEarlierSessionDoesNotEndANewerOne() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-7200), endsAt: now.addingTimeInterval(3600))) + let earlier = try Data(contentsOf: h.home.paths.sessionFile) + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-60), endsAt: now.addingTimeInterval(3600))) + XCTAssertNotEqual(try Data(contentsOf: h.home.paths.sessionFile), earlier) + let stale = h.home.paths.appSupport.appendingPathComponent("ended-session.json.Stale001") + try earlier.write(to: stale) + var journal = RuntimeState() + journal.sleepDisabledByUs = true + try h.store.saveState(journal) + h.guardFake.sleepDisabled = true // the newer session's hold + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path), "a record that matches nothing goes") + + try earlier.write(to: stale) + let m = h.makeManager() + await m.reconcile() + XCTAssertTrue(m.isActive, logText()) + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 1")) + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive) + + _ = await m.end(reason: .user) + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [], "an end removes the records with session.json") + } + + /// Only a regular file with exactly the record's name is a record + /// aside: a symlink to a copy of session.json, a FIFO and other names + /// never end the session, the FIFO is never opened, and neither side + /// removes any of them. A record nobody can read is not removed as + /// stale and ends nothing. + func testOnlyARegularFileOfTheRecordsShapeIsARecordAside() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + let dir = h.home.paths.appSupport + let copy = h.home.root.appendingPathComponent("copy-of-session") + try bytes.write(to: copy) + let link = dir.appendingPathComponent("ended-session.json.Link0000") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: copy) + let other = dir.appendingPathComponent("ended-session.json.Other0000") + try bytes.write(to: other) + let unreadable = dir.appendingPathComponent("ended-session.json.NoRead00") + try bytes.write(to: unreadable) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + let fifo = try FIFOWatch(at: dir.appendingPathComponent("ended-session.json.Fifo0000")) + defer { fifo.stop() } + + XCTAssertEqual(h.store.sessionEndRecordsAside(), [unreadable]) + XCTAssertNil(h.store.sessionEndRecordAside()) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try await runAgent(expecting: 0) + XCTAssertFalse(fifo.readerSeen, "a FIFO named like a record was opened") + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + for kept in [link, other, unreadable] { + XCTAssertNotNil(try? FileManager.default.attributesOfItem(atPath: kept.path), kept.lastPathComponent) + } + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive) + XCTAssertFalse(fifo.readerSeen) + } + + // MARK: The record in the log folder + + private var logs: URL { h.home.paths.logs } + + /// The round-22 review's files: session.json, an unrelated + /// ended-session.json and state.json all immutable, and the folder + /// holding them takes no new file. The agent (the app dead) cannot + /// remove session.json or write any record there, so it writes the + /// record in the log folder, reads it back, and only then restores + /// sleep. Its status files fail in that folder too, so the run exits 1 + /// once its supervisor reports no result. The fake commands get a 2 s + /// limit instead of 30 s: the run waits for that missing status for the + /// limit plus the grace either way, and the fakes finish at once. + private func endRecordedInTheLogFolder(restoreFails: Bool) async throws -> URL { + _ = try await startThenPin() + lockInodeAtStart = try lockFileInode() + try agent.setCommandTimeout(2) + try setImmutable(h.home.paths.stateFile, true) + if restoreFails { try agent.failSudo() } + try TestACL.denyNewFiles(in: h.home.paths.appSupport) + defer { try? TestACL.removeAll(h.home.paths.appSupport) } + + try await runAgent(expecting: 1) + + let record = try recordAside() + XCTAssertEqual(record.deletingLastPathComponent().resolvingSymlinksInPath(), logs.resolvingSymlinksInPath()) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertTrue(agent.logsAtSudo.contains(record.lastPathComponent), "recorded before sleep was restored: \(agent.logsAtSudo)") + XCTAssertFalse(agent.namesAtSudo.contains { Paths.isEndedSessionAsideName($0) }, "\(agent.namesAtSudo)") + XCTAssertEqual(try Data(contentsOf: h.home.paths.endedSessionFile), unrelatedRecord) + XCTAssertNil(try h.store.loadState()?.endedSession) + XCTAssertFalse(h.store.sessionEndIsRecorded()) + XCTAssertTrue(logText().contains("its end is recorded in \(record.path) instead"), logText()) + var info = stat() + XCTAssertEqual(lstat(record.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + // The recovery lock file is the last place, unused while a folder + // takes the record. + XCTAssertEqual(h.store.lockEndRecord(), .none) + XCTAssertEqual(try lockFileInode(), lockInodeAtStart) + return record + } + + private var lockInodeAtStart: UInt64? + + private func lockFileInode() throws -> UInt64 { + try XCTUnwrap((try FileManager.default.attributesOfItem(atPath: h.home.paths.recoveryLock.path))[.systemFileNumber] as? UInt64) + } + + /// Then the folder and state.json are repaired, SleepDisabled reads 1 + /// (the restore failed, or something else set it again) and the app + /// launches first, holding the alive lock. The record in the log folder + /// ends the session: no disablesleep 1. A later agent run ends it again + /// without the checks, and once session.json can be removed, it and the + /// record go. + private func relaunchAfterTheFolderAndJournalAreRepaired(_ record: URL) async throws { + try TestACL.removeAll(h.home.paths.appSupport) + try setImmutable(h.home.paths.stateFile, false) + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "the agent's end survives the folder and journal repair") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("reconcile: session.json holds a session already ended (recorded in \(record.lastPathComponent)); restoring, not resuming"), logText()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record], "the app's end reuses the record") + XCTAssertEqual(try h.store.loadState()?.endedSession, try marker(), "now the journal records it too") + + let callsBefore = agent.calls.count + try await runAgent(expecting: 1) + XCTAssertFalse(agent.calls.dropFirst(callsBefore).contains("pmset -g batt"), "no checks for a session recorded as ended") + + try unpinAll() + try await runAgent(expecting: 0) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [], "the record goes with the file it copies") + XCTAssertFalse(FileManager.default.fileExists(atPath: record.path)) + XCTAssertEqual(h.store.lockEndRecord(), .none) + XCTAssertEqual(try lockFileInode(), lockInodeAtStart) + } + + func testAFailedRestoreRecordedInTheLogFolderIsNotResumedOnceTheFolderAndJournalAreRepaired() async throws { + let record = try await endRecordedInTheLogFolder(restoreFails: true) + try await relaunchAfterTheFolderAndJournalAreRepaired(record) + } + + func testAnotherHoldDoesNotResumeASessionRecordedInTheLogFolder() async throws { + let record = try await endRecordedInTheLogFolder(restoreFails: false) + try await relaunchAfterTheFolderAndJournalAreRepaired(record) + } + + /// The app ends its own session in the same files: it writes the + /// record in the log folder before it restores anything, and a + /// relaunch and the agent both treat the session as over. + func testAnAppEndThatCanWriteOnlyInTheLogFolderIsHonouredByARelaunchAndTheAgent() async throws { + let m = try await startThenPin() + try setImmutable(h.home.paths.stateFile, true) + try TestACL.denyNewFiles(in: h.home.paths.appSupport) + defer { try? TestACL.removeAll(h.home.paths.appSupport) } + let gate = AsyncGate() + h.guardFake.restoreGate = gate + + let end = Task { @MainActor in _ = await m.end(reason: .user) } + await gate.waitUntilStarted() + let record = try recordAside() + XCTAssertEqual(record.deletingLastPathComponent().resolvingSymlinksInPath(), logs.resolvingSymlinksInPath()) + await gate.open() + await end.value + h.guardFake.restoreGate = nil + + XCTAssertFalse(m.isActive) + XCTAssertTrue(h.notifier.posts.contains { $0.body.contains("its end is recorded, so a relaunch will not resume it") }, "\(h.notifier.posts)") + try TestACL.removeAll(h.home.paths.appSupport) + + h.guardFake.sleepDisabled = true + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(record.path))"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt"), agent.calls.joined(separator: "\n")) + XCTAssertEqual(h.store.sessionEndRecordsAside(), [record]) + } + + /// A running app's tick sees a record in the log folder and ends its + /// side; one of an earlier session.json ends nothing, and the agent + /// removes it. + func testTheRunningAppAndTheAgentReadTheLogFolder() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + let stale = logs.appendingPathComponent("ended-session.json.Stale001") + try Data("an earlier session.json".utf8).write(to: stale) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive, "a record of another session.json ends nothing") + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path), "a record that matches nothing goes") + + let record = logs.appendingPathComponent("ended-session.json.Match001") + try bytes.write(to: record) + let before = h.guardFake.calls.count + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: record.path), "the end removes the record with session.json") + } + + /// In the log folder as beside session.json: a symlink to a copy of + /// session.json, a FIFO and other names never end the session, the + /// FIFO is never opened, and neither side removes any of them. A record + /// nobody can read is not removed as stale and ends nothing. A log + /// folder that is a symlink is not searched at all, by either side, and + /// the app does not write a record through it. + func testOnlyARegularFileInTheRealLogFolderIsARecord() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + let copy = h.home.root.appendingPathComponent("copy-of-session") + try bytes.write(to: copy) + let link = logs.appendingPathComponent("ended-session.json.Link0000") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: copy) + let other = logs.appendingPathComponent("ended-session.json.Other0000") + try bytes.write(to: other) + let unreadable = logs.appendingPathComponent("ended-session.json.NoRead00") + try bytes.write(to: unreadable) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + let fifo = try FIFOWatch(at: logs.appendingPathComponent("ended-session.json.Fifo0000")) + defer { fifo.stop() } + + XCTAssertEqual(h.store.sessionEndRecordsAside(), [unreadable]) + XCTAssertNil(h.store.sessionEndRecordAside()) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try await runAgent(expecting: 0) + XCTAssertFalse(fifo.readerSeen, "a FIFO named like a record was opened") + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + for kept in [link, other, unreadable] { + XCTAssertNotNil(try? FileManager.default.attributesOfItem(atPath: kept.path), kept.lastPathComponent) + } + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive) + XCTAssertFalse(fifo.readerSeen) + + // The log folder replaced by a symlink to a folder holding a + // matching record. + let elsewhere = h.home.root.appendingPathComponent("elsewhere", isDirectory: true) + try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) + try bytes.write(to: elsewhere.appendingPathComponent("ended-session.json.Elsewher")) + let realLogs = h.home.root.appendingPathComponent("real-logs", isDirectory: true) + try FileManager.default.moveItem(at: logs, to: realLogs) + try FileManager.default.createSymbolicLink(at: logs, withDestinationURL: elsewhere) + defer { + try? FileManager.default.removeItem(at: logs) + try? FileManager.default.moveItem(at: realLogs, to: logs) + } + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive, "a record through a symlinked log folder ends nothing") + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession(), "the agent ends nothing on a record through a symlinked log folder") + XCTAssertTrue(FileManager.default.fileExists(atPath: elsewhere.appendingPathComponent("ended-session.json.Elsewher").path)) + + try setImmutable(h.home.paths.sessionFile, true) + try setImmutable(h.home.paths.stateFile, true) + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + try setImmutable(h.home.paths.endedSessionFile, true) + try TestACL.denyNewFiles(in: h.home.paths.appSupport) + defer { try? TestACL.removeAll(h.home.paths.appSupport) } + XCTAssertNil(h.store.recordSessionEndAside(), "no record is written through the symlink") + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: elsewhere.path), ["ended-session.json.Elsewher"]) + } + + /// Every place refuses the record: session.json, ended-session.json and + /// state.json immutable, neither the folder holding them nor the log + /// folder takes a new file, and neither the recovery lock file nor + /// insomnia.log takes a record (LockEndRecordTests and + /// LogEndRecordTests cover the same case with a file that takes it). + /// The agent still restores sleep (here it fails, so + /// SleepDisabled stays 1) and keeps sleepDisabledByUs. Then both + /// folders and state.json are repaired and the app launches first. + /// session.json still cannot be replaced, so the app does not hold + /// sleep again for it: it ends it and records the end in the journal. + /// The 2 s limit on the fakes is as in endRecordedInTheLogFolder. + func testAnEndRecordedNowhereIsNotResumedWhileSessionJSONCannotBeReplaced() async throws { + _ = try await startThenPin() + try agent.setCommandTimeout(2) + try setImmutable(h.home.paths.stateFile, true) + try agent.failSudo() + try agent.refuseLockRecord() + try agent.refuseLogRecord() + try TestACL.denyNewFiles(in: h.home.paths.appSupport) + try TestACL.denyNewFiles(in: logs) + defer { + try? TestACL.removeAll(h.home.paths.appSupport) + try? TestACL.removeAll(logs) + } + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertFalse(h.store.sessionEndIsRecorded()) + XCTAssertNil(try h.store.loadState()?.endedSession) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + XCTAssertTrue(logText().contains("could not remove \(h.home.paths.sessionFile.path) or record its end in \(h.home.paths.endedSessionFile.path), \(h.home.paths.stateFile.path), a new file in \(h.home.paths.appSupport.path) or \(logs.path), the recovery lock file \(h.home.paths.recoveryLock.path), or the log file \(h.home.paths.logFile.path)"), logText()) + XCTAssertEqual(h.store.lockEndRecord(), .none) + + try TestACL.removeAll(h.home.paths.appSupport) + try TestACL.removeAll(logs) + try setImmutable(h.home.paths.stateFile, false) + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let marker = try marker() + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, "a session whose end may have gone unrecorded must not come back") + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled, "the relaunch restores the hold the agent could not") + XCTAssertTrue(logText().contains("but it cannot be replaced, so an end of it may have gone unrecorded"), logText()) + XCTAssertEqual(try h.store.loadState()?.endedSession, marker) + } + + /// The cost of that rule: a session the app was running when it died, + /// with nothing ended, is not resumed either while session.json cannot + /// be replaced. The same crash with a writable session.json resumes. + func testACrashedSessionWhoseFileCannotBeReplacedIsEndedNotResumed() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let session = try Data(contentsOf: h.home.paths.sessionFile) + let journal = try Data(contentsOf: h.home.paths.stateFile) + try setImmutable(h.home.paths.sessionFile, true) + + var before = h.guardFake.calls.count + let pinned = h.makeManager() + await pinned.reconcile() + XCTAssertFalse(pinned.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("but it cannot be replaced"), logText()) + + // Undo that end: the same crash, but session.json can be replaced. + try setImmutable(h.home.paths.sessionFile, false) + try? FileManager.default.removeItem(at: h.home.paths.endedSessionFile) + try session.write(to: h.home.paths.sessionFile) + try journal.write(to: h.home.paths.stateFile) + h.guardFake.sleepDisabled = true + before = h.guardFake.calls.count + let control = h.makeManager() + await control.reconcile() + XCTAssertTrue(control.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertEqual(try Data(contentsOf: h.home.paths.sessionFile), session, "the rewrite keeps the same bytes") + } + + /// The Store's side: a record goes in the log folder only when the + /// folder beside session.json takes no new file, is found there, and + /// is removed with session.json. + func testTheStoreWritesARecordInTheLogFolderOnlyWhenItsOwnFolderRefuses() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + try TestACL.denyNewFiles(in: h.home.paths.appSupport) + defer { try? TestACL.removeAll(h.home.paths.appSupport) } + let record = try XCTUnwrap(h.store.recordSessionEndAside()) + XCTAssertEqual(record.deletingLastPathComponent().resolvingSymlinksInPath(), logs.resolvingSymlinksInPath()) + XCTAssertEqual(try Data(contentsOf: record), try Data(contentsOf: h.home.paths.sessionFile)) + var info = stat() + XCTAssertEqual(lstat(record.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + XCTAssertEqual(h.store.recordSessionEndAside(), record, "used again while it matches") + try TestACL.removeAll(h.home.paths.appSupport) + XCTAssertEqual(h.store.recordSessionEndAside(), record, "found in the log folder once the other takes files again") + try h.store.deleteSession() + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertFalse(FileManager.default.fileExists(atPath: record.path)) + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let beside = try XCTUnwrap(h.store.recordSessionEndAside()) + XCTAssertEqual(beside.deletingLastPathComponent().resolvingSymlinksInPath(), h.home.paths.appSupport.resolvingSymlinksInPath(), "beside session.json first") + } + + /// The app's record aside: created 0600 under a fresh name, used again + /// while it matches, nil when session.json is gone, and removed with + /// session.json. + func testTheStoreWritesOneRecordAsideAndRemovesItWithTheSession() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let record = try XCTUnwrap(h.store.recordSessionEndAside()) + XCTAssertTrue(Paths.isEndedSessionAsideName(record.lastPathComponent), record.lastPathComponent) + XCTAssertEqual(try Data(contentsOf: record), try Data(contentsOf: h.home.paths.sessionFile)) + var info = stat() + XCTAssertEqual(lstat(record.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + XCTAssertEqual(h.store.recordSessionEndAside(), record) + XCTAssertEqual(h.store.sessionEndRecordAside(), record) + try h.store.deleteSession() + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertNil(h.store.recordSessionEndAside()) + for name in ["ended-session.json.Abcd123", "ended-session.json.Abcd12345", "ended-session.json.Abcd-123", "ended-session.json.Abcd123é"] { + XCTAssertFalse(Paths.isEndedSessionAsideName(name), name) + } + XCTAssertTrue(Paths.isEndedSessionAsideName("ended-session.json.Abcd1234")) + } +} diff --git a/Tests/InsomniaTests/LaunchGateTests.swift b/Tests/InsomniaTests/LaunchGateTests.swift new file mode 100644 index 00000000..40e8617c --- /dev/null +++ b/Tests/InsomniaTests/LaunchGateTests.swift @@ -0,0 +1,181 @@ +import Foundation +import XCTest +@testable import Insomnia + +/// A second Insomnia (`open -n`, or the binary run directly) cannot take the +/// alive lock. It must not reconcile, start the app or touch power state: +/// backstop.sh sees only the other copy's lock, so a session this copy +/// owned would outlive its crash. It says why and quits. +@MainActor +final class LaunchGateTests: XCTestCase { + var h: Harness! + + override func setUp() async throws { h = Harness() } + override func tearDown() async throws { h.home.destroy() } + + /// An expired session over a dirty journal, which any reconcile + /// restores. Returns the journal's bytes. + private func leaveAnExpiredSession() throws -> Data { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-7200), endsAt: now.addingTimeInterval(-60))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true + return try Data(contentsOf: h.home.paths.stateFile) + } + + private func gate(_ lock: AppAliveLock, timeout: TimeInterval = 0.3) -> LaunchGate { + LaunchGate(aliveLock: lock, notifier: h.notifier, timeout: timeout) + } + + func testACopyWithoutTheAliveLockNeitherStartsNorReconciles() async throws { + let journal = try leaveAnExpiredSession() + let other = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try other.tryAcquire()) + defer { other.release() } + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + var started = false + + let opened = await gate(mine).open(manager: h.makeManager(), start: { started = true }) + + XCTAssertFalse(opened) + XCTAssertFalse(started, "no menu, no settings window, no login item check") + XCTAssertFalse(mine.isHeld) + XCTAssertEqual(h.guardFake.calls, [], "power state untouched") + XCTAssertEqual(try Data(contentsOf: h.home.paths.stateFile), journal) + XCTAssertNotNil(try h.store.loadSession()) + let posts = h.notifier.posts + XCTAssertEqual(posts.map(\.title), [LaunchGate.anotherCopyTitle]) + XCTAssertTrue(posts.first?.body.contains(h.home.paths.appAliveFile.path) == true, "\(posts)") + } + + /// A lock that cannot be taken at all (here a directory at its path) + /// proves nothing about another copy, so it stops the launch the same way. + func testALockThatCannotBeTakenAtAllAlsoStopsTheLaunch() async throws { + let journal = try leaveAnExpiredSession() + try FileManager.default.createDirectory(at: h.home.paths.appAliveFile, withIntermediateDirectories: true) + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + var started = false + + let opened = await gate(mine).open(manager: h.makeManager(), start: { started = true }) + + XCTAssertFalse(opened) + XCTAssertFalse(started) + XCTAssertEqual(h.guardFake.calls, []) + XCTAssertEqual(try Data(contentsOf: h.home.paths.stateFile), journal) + XCTAssertEqual(h.notifier.posts.map(\.title), [LaunchGate.lockFailedTitle]) + } + + /// The copy that takes the lock starts the app, then reconciles. + func testTheCopyThatTakesTheLockStartsAndThenReconciles() async throws { + _ = try leaveAnExpiredSession() + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + defer { mine.release() } + let fake = h.guardFake + var callsAtStart: [String]? + + let opened = await gate(mine).open(manager: h.makeManager(), start: { callsAtStart = fake.calls }) + + XCTAssertTrue(opened) + XCTAssertTrue(mine.isHeld) + XCTAssertEqual(callsAtStart, [], "start runs before reconcile") + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + XCTAssertFalse(h.notifier.posts.contains { $0.title == LaunchGate.anotherCopyTitle }) + } + + /// backstop.sh's probe holds the lock for a moment; the wait outlasts it. + func testABriefHoldSuchAsABackstopProbeIsWaitedOut() async throws { + let probe = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try probe.tryAcquire()) + let letGo = Task { + try await Task.sleep(for: .milliseconds(150)) + probe.release() + } + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + defer { mine.release() } + var started = false + + let opened = await gate(mine, timeout: 3).open(manager: h.makeManager(), start: { started = true }) + try await letGo.value + + XCTAssertTrue(opened) + XCTAssertTrue(started) + XCTAssertTrue(mine.isHeld) + } + + /// The journal the copy that owns the session left: a lid close muted + /// the USB headset, and the session ended while it was unplugged, so + /// its entry waits for it to reconnect. Returns the journal's bytes. + private func leaveAHeadsetOwedItsVolume() throws -> Data { + var st = RuntimeState() + st.savedAudioOutputs = [SavedAudioOutput(deviceUID: "usb-headset", name: "USB Headset", volume: 0.3, muted: false, saveID: UUID().uuidString)] + try h.store.saveState(st) + return try Data(contentsOf: h.home.paths.stateFile) + } + + /// A second copy registers for CoreAudio's device changes only once it + /// holds the alive lock. The headset reconnects while the copy waits at + /// the gate and again after it is refused: it stays muted and the other + /// copy's journal is untouched. + func testACopyWaitingAtTheGateOrRefusedThereRestoresNoReconnectedDevice() async throws { + let journal = try leaveAHeadsetOwedItsVolume() + let other = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try other.tryAcquire()) + defer { other.release() } + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + let m = h.makeManager() + XCTAssertFalse(h.audio.watched, "init does not register") + let audio = h.audio + let reconnect = Task { @MainActor in + try await Task.sleep(for: .milliseconds(100)) + audio.connect("usb-headset", name: "USB Headset", volume: 0.3, muted: true) + audio.fireDevicesChanged() + } + + let opened = await gate(mine, timeout: 0.5).open(manager: m, start: {}) + try await reconnect.value + h.audio.fireDevicesChanged() + try await Task.sleep(for: .milliseconds(300)) + + XCTAssertFalse(opened) + XCTAssertFalse(h.audio.watched) + XCTAssertEqual(h.audio.applied.count, 0) + XCTAssertEqual(h.audio.device("usb-headset")?.muted, true, "the headset stays muted") + XCTAssertEqual(try Data(contentsOf: h.home.paths.stateFile), journal) + XCTAssertEqual(h.guardFake.calls, []) + } + + /// The copy that takes the lock registers before it reconciles: the + /// headset, unplugged at launch, gets its volume back when it + /// reconnects. + func testTheCopyThatTakesTheLockRestoresADeviceWhenItReconnects() async throws { + _ = try leaveAHeadsetOwedItsVolume() + let mine = AppAliveLock(url: h.home.paths.appAliveFile) + defer { mine.release() } + let m = h.makeManager() + let audio = h.audio + var watchedAtStart: Bool? + + let opened = await gate(mine).open(manager: m, start: { watchedAtStart = audio.watched }) + + XCTAssertTrue(opened) + XCTAssertEqual(watchedAtStart, true, "registered once the lock is held, before start and reconcile") + XCTAssertEqual(h.audio.applied.count, 0, "unplugged at launch: the entry waits") + XCTAssertEqual(try h.store.loadState()?.savedAudioOutputs.map(\.deviceUID), ["usb-headset"]) + + h.audio.connect("usb-headset", name: "USB Headset", volume: 0.3, muted: true) + h.audio.fireDevicesChanged() + for _ in 0..<300 where h.audio.applied.isEmpty { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(h.audio.applied.map { $0.deviceUID }, ["usb-headset"]) + XCTAssertEqual(h.audio.device("usb-headset")?.muted, false) + XCTAssertEqual(h.audio.device("usb-headset")?.volume, 0.3) + for _ in 0..<300 where (try? h.store.loadState()) != RuntimeState.clean { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + } +} diff --git a/Tests/InsomniaTests/LaunchdBackstopTests.swift b/Tests/InsomniaTests/LaunchdBackstopTests.swift index 0f3235cf..6f535beb 100644 --- a/Tests/InsomniaTests/LaunchdBackstopTests.swift +++ b/Tests/InsomniaTests/LaunchdBackstopTests.swift @@ -222,7 +222,13 @@ final class LaunchdBackstopTests: XCTestCase { let exec = try XCTUnwrap(p.range(of: #"&& exec /bin/bash "$2/Contents/Resources/backstop.sh""#)) XCTAssertLessThan(verify.lowerBound, exec.lowerBound, "exec must follow a successful verify") XCTAssertTrue(p.hasSuffix("; exit 1"), "a failed verification ends the program: \(p)") - XCTAssertTrue(p.contains(#">> "$HOME/Library/Logs/Insomnia/insomnia.log""#), "the refusal is logged where the app logs") + XCTAssertTrue(p.contains(#"f="$HOME/Library/Logs/Insomnia/insomnia.log"; "#), "the refusal is logged where the app logs") + XCTAssertEqual(p.components(separatedBy: #"8>>"$f""#).count, 2, "one descriptor on the log") + XCTAssertEqual(p.components(separatedBy: ">>").count, 2, "no write but through that descriptor") + XCTAssertEqual(p.components(separatedBy: ">&8").count, 3, "a newline after a line cut short, then the refusal") + let lock = try XCTUnwrap(p.range(of: "/usr/bin/lockf -s -t 5 8 || exit 1;")) + let tail = try XCTUnwrap(p.range(of: "/usr/bin/tail -c 1")) + XCTAssertLessThan(lock.lowerBound, tail.lowerBound, "the last byte is read under the log's lock") XCTAssertFalse(p.contains("'"), "install.sh holds the program in single quotes") XCTAssertFalse(p.contains("\n"), "one line, so install.sh's AGENT_PROGRAM line stays one line") XCTAssertEqual(p.components(separatedBy: "/bin/bash").count, 2, "exactly one exec target: the sealed script") @@ -243,6 +249,153 @@ final class LaunchdBackstopTests: XCTestCase { XCTAssertEqual(String(line.dropFirst("AGENT_PROGRAM='".count).dropLast()), LaunchdBackstop.agentProgram) } + /// The program run as launchd runs it, in a scratch HOME, with codesign + /// replaced by /usr/bin/false, so the bundle fails the check and + /// nothing is verified or executed. Its refusal line starts on a line + /// of its own after a line cut short: the record of a session's end + /// whose newline alone is missing, a line a write left partway, or a + /// last byte it cannot read in a log this user may only write to. A log + /// that ends in a newline, an empty log and a missing one get no extra + /// newline. + func testAgentProgramsRefusalLineNeverJoinsALineCutShort() throws { + let p = LaunchdBackstop.agentProgram + XCTAssertEqual(p.components(separatedBy: "/usr/bin/codesign").count, 2) + let failing = p.replacingOccurrences(of: "/usr/bin/codesign", with: "/usr/bin/false") + let log = home.root.appendingPathComponent("Library/Logs/Insomnia/insomnia.log") + let record = "\(LogEndRecord.tag) 2 e30=" + let cases: [(name: String, before: String?, mode: Int, lines: [String])] = [ + ("a record without its newline", "a line\n\(record)", 0o600, ["a line", record]), + ("a line cut short", "a line\ncut sh", 0o600, ["a line", "cut sh"]), + ("a whole line", "a line\n", 0o600, ["a line"]), + ("empty", "", 0o600, []), + ("missing", nil, 0o600, []), + ("write-only, cut short", "a line\ncut sh", 0o200, ["a line", "cut sh"]), + ] + for c in cases { + try? FileManager.default.removeItem(at: log) + try FileManager.default.createDirectory(at: log.deletingLastPathComponent(), withIntermediateDirectories: true) + if let before = c.before { + try Data(before.utf8).write(to: log) + try FileManager.default.setAttributes([.posixPermissions: c.mode], ofItemAtPath: log.path) + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", failing, "sh", Self.requirement, home.root.appendingPathComponent("Missing.app").path] + process.environment = ["HOME": home.root.path, "PATH": "/usr/bin:/bin"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let exit = ProcessExit(process) + try process.run() + exit.wait() + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: log.path) + + XCTAssertEqual(process.terminationStatus, 1, c.name) + let lines = try String(contentsOf: log, encoding: .utf8).components(separatedBy: "\n") + XCTAssertEqual(Array(lines.dropLast(2)), c.lines, c.name) + XCTAssertTrue(lines.dropLast().last?.contains("[error] backstop agent: \(home.root.path)/Missing.app does not satisfy") == true, "\(c.name): \(lines)") + XCTAssertEqual(lines.last, "", c.name) + } + } + + /// The program as launchd runs it, refusing the bundle as above, for + /// the log at `log` in this test's HOME. Not yet started. + private struct CodesignStillRuns: Error {} + + /// The program as launchd runs it, in a scratch HOME, with its one + /// codesign call replaced by /usr/bin/false: the bundle fails the check + /// and nothing is verified or executed. Throws, running nothing, if the + /// program would still call codesign. + private func refusingAgent() throws -> Process { + let p = LaunchdBackstop.agentProgram + let failing = p.replacingOccurrences(of: "/usr/bin/codesign", with: "/usr/bin/false") + guard p.components(separatedBy: "/usr/bin/codesign").count == 2, + !failing.contains("/usr/bin/codesign"), !failing.contains("exec /usr/bin/codesign") else { throw CodesignStillRuns() } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", failing, "sh", Self.requirement, home.root.appendingPathComponent("Missing.app").path] + process.environment = ["HOME": home.root.path, "PATH": "/usr/bin:/bin"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + return process + } + + private var agentLog: URL { home.root.appendingPathComponent("Library/Logs/Insomnia/insomnia.log") } + + /// The app writes a line in pieces, every write(2) cut short to four + /// bytes, and holds the log's lock until the last piece. The + /// LaunchAgent's refusal, started after the first piece, writes nothing + /// until then, and its line then follows the app's whole line on a line + /// of its own. + func testTheAgentsLineWaitsForEveryPieceOfAnAppLineCutShort() throws { + try FileManager.default.createDirectory(at: agentLog.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("a line\n".utf8).write(to: agentLog) + let agent = try refusingAgent() + let exit = ProcessExit(agent) + let pieces = Locked(0) + let startError = Locked(nil) + let sizeWhileWaiting = Locked(nil) + let log = agentLog + OwnerOnly.logWriteForTesting = { fd, bytes, count in + pieces.value += 1 + if pieces.value == 2 { + do { + try agent.run() + usleep(500_000) + } catch { + startError.value = "\(error)" + } + sizeWhileWaiting.value = (try? Data(contentsOf: log))?.count + } + return Darwin.write(fd, bytes, min(count, 4)) + } + defer { OwnerOnly.logWriteForTesting = nil } + + try OwnerOnly.appendToLog("the app's line\n", at: agentLog) + OwnerOnly.logWriteForTesting = nil + XCTAssertNil(startError.value) + if agent.processIdentifier > 0 { exit.wait() } + + XCTAssertEqual(agent.terminationStatus, 1) + XCTAssertEqual(pieces.value, 4, "15 bytes, four at a time") + XCTAssertEqual(sizeWhileWaiting.value, "a line\n".utf8.count + 4, "the agent wrote between two pieces of the app's line") + let lines = try String(contentsOf: agentLog, encoding: .utf8).components(separatedBy: "\n") + XCTAssertEqual(lines.count, 4, "\(lines)") + XCTAssertEqual(Array(lines.prefix(2)), ["a line", "the app's line"]) + XCTAssertTrue(lines.dropFirst(2).first?.contains("[error] backstop agent: \(home.root.path)/Missing.app does not satisfy") == true, "\(lines)") + XCTAssertEqual(lines.last, "") + } + + /// The app rotates the log under its lock. The agent's line, waiting + /// for that lock on the file the app renamed, goes to the fresh log the + /// path then names, not into the renamed file, and starts after the + /// line cut short left there. + func testTheAgentsLineFollowsARotationItWaitedFor() throws { + try FileManager.default.createDirectory(at: agentLog.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("old\n".utf8).write(to: agentLog) + let holder = try LogLockHolder(agentLog) + let agent = try refusingAgent() + let exit = ProcessExit(agent) + try agent.run() + defer { + holder.release() + exit.wait() + } + usleep(500_000) + XCTAssertEqual(try String(contentsOf: agentLog, encoding: .utf8), "old\n", "written under another writer's lock") + + XCTAssertEqual(rename(agentLog.path, OwnerOnly.rotated(agentLog).path), 0) + try Data("fresh, cut sh".utf8).write(to: agentLog) + holder.release() + exit.wait() + + XCTAssertEqual(agent.terminationStatus, 1) + XCTAssertEqual(try String(contentsOf: OwnerOnly.rotated(agentLog), encoding: .utf8), "old\n", "the line went into the renamed file") + let lines = try String(contentsOf: agentLog, encoding: .utf8).components(separatedBy: "\n") + XCTAssertEqual(lines.count, 3, "\(lines)") + XCTAssertEqual(lines.first, "fresh, cut sh") + XCTAssertTrue(lines.dropFirst().first?.contains("[error] backstop agent: ") == true, "\(lines)") + } + /// Running from a bundle pins that bundle; `swift run` falls back to the /// installed one so a development build still arms a verifiable agent. func testBundleIsTheRunningOneOrElseTheInstalledOne() { diff --git a/Tests/InsomniaTests/LidActionsTests.swift b/Tests/InsomniaTests/LidActionsTests.swift index ae12715b..07df7237 100644 --- a/Tests/InsomniaTests/LidActionsTests.swift +++ b/Tests/InsomniaTests/LidActionsTests.swift @@ -1168,6 +1168,7 @@ final class LidActionsTests: XCTestCase { /// while Insomnia runs, CoreAudio's device change restores it. func testQuitKeepsTheEntryOfAnUnpluggedDeviceAndItsReconnectRestoresIt() async throws { let (m, _) = await closeOnTheHeadsetAndUnplugIt() + m.watchOutputDevices() // LaunchGate's call once the launch holds the alive lock let outcome = await m.end(reason: .quit) @@ -1440,6 +1441,7 @@ final class LidActionsTests: XCTestCase { s.savedAudioOutputs = [Self.headsetSaved] try h.store.saveState(s) try h.store.saveSession(SessionMath.newSession(now: h.clock.now, duration: 3600, maxDuration: 86400)) + h.guardFake.sleepDisabled = true // the crashed session's hold h.clamshell.closed = true h.audio.connect("usb-headset", name: "USB Headset", volume: 0.3, muted: true) let m = h.makeManager(lockTimeout: 0.05) diff --git a/Tests/InsomniaTests/LidSimulationGateScriptTests.swift b/Tests/InsomniaTests/LidSimulationGateScriptTests.swift index 4cad54d3..ea66d71f 100644 --- a/Tests/InsomniaTests/LidSimulationGateScriptTests.swift +++ b/Tests/InsomniaTests/LidSimulationGateScriptTests.swift @@ -23,7 +23,7 @@ final class LidSimulationGateScriptTests: XCTestCase { private static let otherStrings = "launched\nRestore incomplete" override func setUpWithError() throws { - root = fm.temporaryDirectory.appendingPathComponent("lid-gate-\(UUID().uuidString)", isDirectory: true) + root = ProcessTestHome.temporaryDirectory.appendingPathComponent("lid-gate-\(UUID().uuidString)", isDirectory: true) let scripts = root.appendingPathComponent("scripts", isDirectory: true) let bin = root.appendingPathComponent("bin", isDirectory: true) for dir in [scripts, bin, plain, sim] { @@ -38,7 +38,7 @@ final class LidSimulationGateScriptTests: XCTestCase { // `swift build` succeeds without building; --show-bin-path names the // plain directory, or the sim one for the opt-in scratch path. try stub("swift", """ - for a in "$@"; do [[ $a == --scratch-path ]] && dir=\(sim.path); done + for a in "$@"; do [[ $a == --scratch-path ]] && dir='\(sim.path)'; done for a in "$@"; do [[ $a == --show-bin-path ]] && echo "${dir:-\(plain.path)}"; done exit 0 """) @@ -141,7 +141,7 @@ final class LidSimulationGateScriptTests: XCTestCase { p.executableURL = URL(fileURLWithPath: "/bin/bash") p.arguments = [root.appendingPathComponent("scripts/check-lid-simulation-gate.sh").path] // No stub on PATH: the script reaches them only through the patched lines. - p.environment = ["PATH": "/usr/bin:/bin", "TMPDIR": NSTemporaryDirectory()] + p.environment = ["PATH": "/usr/bin:/bin", "TMPDIR": ProcessTestHome.temporaryDirectory.path + "/"] // Capture to files rather than pipes: nothing to drain, nothing to deadlock. let outURL = root.appendingPathComponent("stdout") let errURL = root.appendingPathComponent("stderr") diff --git a/Tests/InsomniaTests/LockEndRecordTests.swift b/Tests/InsomniaTests/LockEndRecordTests.swift new file mode 100644 index 00000000..9658715d --- /dev/null +++ b/Tests/InsomniaTests/LockEndRecordTests.swift @@ -0,0 +1,1457 @@ +import Darwin +import Foundation +import XCTest +@testable import Insomnia + +/// The last place the end of a session is recorded: the recovery lock file, +/// for an end that cannot remove session.json while ended-session.json, the +/// journal and both folders of records aside refuse the record. The file +/// exists already, so the record needs no new file; it is written in place, +/// so the file keeps its inode and stays the lock the app and the agent +/// take. Content read whole that is not a whole record ends no session, +/// unless it is the record of the session in session.json cut short as a +/// writer leaves it (its first bytes, or the whole record with old bytes +/// after it), which counts as that session's end; a file that cannot be +/// read counts as the end of whatever session.json holds until that file +/// is gone. The agent is the real backstop.sh with its tools patched to +/// fakes (PatchedBackstop). +@MainActor +final class LockEndRecordTests: XCTestCase { + var h: Harness! + var agent: PatchedBackstop! + let acls = OwnedACLs() + + override func setUp() async throws { + h = Harness() + try h.home.paths.createDirectories() + agent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent", isDirectory: true)) + } + + override func tearDown() async throws { + Store.lockReadErrnoForTesting = nil + Store.lockReadFailuresForTesting = nil + RecoveryLockHandle.pwriteForTesting = nil + try? FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) + acls.removeGiven() + unpinAll() + h.home.destroy() + } + + private var lockFile: URL { h.home.paths.recoveryLock } + private let unrelatedRecord = Data("an end record of some other session.json".utf8) + + private func inode(_ url: URL) throws -> UInt64 { + var st = stat() + guard lstat(url.path, &st) == 0 else { throw POSIXError(.ENOENT) } + return UInt64(st.st_ino) + } + + private func lockBytes() -> Data? { try? Data(contentsOf: lockFile) } + + private func record(of data: Data) -> Data { + Data("\(Store.lockEndRecordTag) \(data.base64EncodedString())\n".utf8) + } + + /// Writes `data` into the file at `url` in place, keeping its inode. + private func writeInPlace(_ data: Data, to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: data) + try handle.close() + } + + private func logText() -> String { + (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + } + + private func logText(since mark: Int) -> String { String(logText().dropFirst(mark)) } + + private func sleepHeldAgain(since count: Int) -> Bool { + h.guardFake.calls.dropFirst(count).contains("disablesleep 1") + } + + private func runAgent(expecting status: Int32, file: StaticString = #filePath, line: UInt = #line) async throws { + let exit = try await agent.run() + XCTAssertEqual(exit, status, logText(), file: file, line: line) + } + + private var pinnable: [URL] { [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] } + + /// A running session whose files take no record: session.json, an + /// unrelated ended-session.json and state.json are immutable. + private func startThenPinAll() async throws -> SessionManager { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + for file in pinnable { try setImmutable(file, true) } + return m + } + + private func unpinAll() { + for file in pinnable { try? setImmutable(file, false) } + } + + private let endedInLockLine = "reconcile: session.json holds a session already ended (recorded in .recovery.lock); restoring, not resuming" + + private var cutShortLine: String { + "already ended (recorded in \(lockFile.path), which holds this session's end record cut short, so it counts as one)" + } + + /// A session.json an earlier session left, written directly. + private let earlier = Data(#"{"endsAt":"2027-01-15T08:30:00Z","extensions":[],"startedAt":"2027-01-15T08:00:00Z"}"#.utf8) + + /// session.json, state.json and the lock file as a crash would leave + /// them; nil for a file that is not there. + private struct DiskCopy: Equatable { + let session: Data? + let state: Data? + let lock: Data? + } + + private func diskCopy() -> DiskCopy { + DiskCopy(session: try? Data(contentsOf: h.home.paths.sessionFile), + state: try? Data(contentsOf: h.home.paths.stateFile), + lock: lockBytes()) + } + + /// Puts a copy back, the lock file in place. + private func putBack(_ copy: DiskCopy) throws { + for (url, data) in [(h.home.paths.sessionFile, copy.session), (h.home.paths.stateFile, copy.state)] { + if let data { + try data.write(to: url) + } else if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } + try writeInPlace(copy.lock ?? Data(), to: lockFile) + } + + /// One agent run while the app is running (its alive lock held). + private func runAgentBesideTheApp() async throws -> (status: Int32, log: String) { + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try agent.clearCalls() + let mark = logText().count + let status = try await agent.run() + return (status, logText(since: mark)) + } + + // MARK: Reading + + /// The app's reader (Store.lockEndRecord) and the agent's + /// (read_lock_record) on the same bytes, one fresh home per case: + /// whether they count the session in session.json as ended, and that + /// the agent empties a whole record of other bytes and content that is + /// no record while the session stays. This session's record cut short + /// (its first bytes, or the whole record and more) counts as its end + /// for both; another session's record cut short past the first byte + /// where the two differ does not. The app is alive and the machine + /// within every floor, so the agent ends only a session it finds + /// recorded as ended. The homes are set up one at a time (each Harness + /// points INSOMNIA_HOME at its own), and the agent runs go several at a + /// time. + func testTheAppAndTheAgentReadEveryShapeOfContentAlike() async throws { + enum Kind { case none, record, foreign } + let tag = Store.lockEndRecordTag + let other = Data("an earlier session.json".utf8).base64EncodedString() + let cases: [(name: String, kind: Kind, ends: Bool, content: (String) -> Data)] = [ + ("empty", .none, false, { _ in Data() }), + ("this session", .record, true, { Data("\(tag) \($0)\n".utf8) }), + ("another session", .record, false, { _ in Data("\(tag) \(other)\n".utf8) }), + ("no newline", .foreign, true, { Data("\(tag) \($0)".utf8) }), + ("two newlines", .foreign, true, { Data("\(tag) \($0)\n\n".utf8) }), + ("carriage return", .foreign, false, { Data("\(tag) \($0)\r\n".utf8) }), + ("other tag", .foreign, false, { Data("ended-session-v2 \($0)\n".utf8) }), + ("two spaces", .foreign, false, { Data("\(tag) \($0)\n".utf8) }), + ("cut base64", .foreign, false, { Data("\(tag) \($0.dropLast())\n".utf8) }), + ("cut short", .foreign, true, { Data("\(tag) \($0)\n".utf8.prefix(10)) }), + ("first byte", .foreign, true, { Data("\(tag) \($0)\n".utf8.prefix(1)) }), + ("cut inside the base64", .foreign, true, { Data("\(tag) \($0)\n".utf8.prefix(tag.utf8.count + 1 + $0.utf8.count / 2)) }), + ("record and old bytes", .foreign, true, { Data("\(tag) \($0)\n".utf8) + Data(repeating: 0x41, count: 4096) }), + ("another session cut short", .foreign, false, { _ in Data("\(tag) \(other)\n".utf8.prefix(tag.utf8.count + 1 + 8)) }), + ("three pads", .foreign, false, { _ in Data("\(tag) Q===\n".utf8) }), + ("pads only", .foreign, false, { _ in Data("\(tag) ====\n".utf8) }), + ("pad inside", .foreign, false, { _ in Data("\(tag) QQ==QQ==\n".utf8) }), + ("NUL byte", .foreign, false, { Data("\(tag) \($0)".utf8) + Data([0]) + Data("\n".utf8) }), + ("trailing byte", .foreign, true, { Data("\(tag) \($0)\nx".utf8) }), + ("newline only", .foreign, false, { _ in Data("\n".utf8) }), + ("other text", .foreign, false, { _ in Data("pid 4242\n".utf8) }), + ("over the bound", .foreign, false, { _ in Data(repeating: 0x41, count: Store.lockEndRecordMaxBytes + 1) }), + ] + var homes: [Harness] = [] + defer { homes.forEach { $0.home.destroy() } } + // Each row keeps its manager, as the app runs beside its agent. + var rows: [(home: Harness, agent: PatchedBackstop, manager: SessionManager, alive: AppAliveLock, lockInode: UInt64)] = [] + for c in cases { + let home = Harness() + homes.append(home) + try home.home.paths.createDirectories() + let caseAgent = try PatchedBackstop(home: home.home.root, dir: home.home.root.appendingPathComponent("agent", isDirectory: true)) + let m = home.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, c.name) + let marker = try XCTUnwrap(home.store.sessionEndMarker()) + let lock = home.home.paths.recoveryLock + let lockInode = try inode(lock) + let content = c.content(marker) + try writeInPlace(content, to: lock) + + switch (c.kind, Store.parseLockEndRecord(content)) { + case (.none, .none), (.record, .record), (.foreign, .foreign): break + case let (_, parsed): XCTFail("\(c.name): parsed as \(parsed)") + } + if content.count > Store.lockEndRecordMaxBytes { + XCTAssertEqual(home.store.lockEndRecord(), .foreign("it holds \(content.count) bytes, more than an end record"), c.name) + } + XCTAssertEqual(home.store.sessionEndRecordedInLock() != nil, c.ends, c.name) + + let alive = AppAliveLock(url: home.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + rows.append((home, caseAgent, m, alive, lockInode)) + } + + let statuses = try await PatchedBackstop.runAll(rows.map(\.agent)) + + for (c, (row, status)) in zip(cases, zip(rows, statuses)) { + let (home, caseAgent, lockInode) = (row.home, row.agent, row.lockInode) + row.alive.release() + let lock = home.home.paths.recoveryLock + let log = (try? String(contentsOf: home.home.paths.logFile, encoding: .utf8)) ?? "" + XCTAssertEqual(status, 0, "\(c.name): \(log)") + XCTAssertEqual(try home.store.loadSession() == nil, c.ends, "\(c.name): \(log)") + XCTAssertEqual(log.contains("already ended (recorded in \(lock.path)"), c.ends, "\(c.name): \(log)") + XCTAssertEqual(log.contains("already ended (recorded in \(lock.path), which holds this session's end record cut short, so it counts as one)"), c.ends && c.kind == .foreign, "\(c.name): \(log)") + XCTAssertEqual(caseAgent.calls.contains("pmset -g batt"), !c.ends, "\(c.name): checked only when not ended") + // Emptied once its session.json is gone, and a whole record of + // other bytes or content that is no record and not this + // session's record cut short at once. + XCTAssertEqual(try Data(contentsOf: lock), Data(), c.name) + XCTAssertEqual(log.contains("emptying \(lock.path): "), c.kind == .foreign, "\(c.name): \(log)") + XCTAssertEqual(try inode(lock), lockInode, c.name) + } + } + + /// Content read whole that is not a whole record ends no session, a + /// file over the bound included, which a start does not keep. A file + /// the app cannot read counts as the end of the session in + /// session.json, as it may hold its record, and ends nothing once + /// session.json is gone. A directory at the path is no record. + func testContentThatIsNoRecordEndsNothingAndAnUnreadableFileCountsAsTheEnd() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + FileManager.default.createFile(atPath: lockFile.path, contents: Data("\(Store.lockEndRecordTag) QUJ".utf8)) + XCTAssertEqual(h.store.lockEndRecord(), .foreign("it holds bytes other than one whole end record")) + XCTAssertNil(h.store.sessionEndRecordedInLock()) + XCTAssertEqual(h.store.lockContents(), Data("\(Store.lockEndRecordTag) QUJ".utf8)) + + let large = Data(repeating: 0x41, count: Store.lockEndRecordMaxBytes + 1) + try writeInPlace(large, to: lockFile) + XCTAssertEqual(h.store.lockEndRecord(), .foreign("it holds \(large.count) bytes, more than an end record")) + XCTAssertNil(h.store.sessionEndRecordedInLock()) + XCTAssertEqual(h.store.lockContents(), Data(), "a start does not put back content that ends nothing") + + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + try writeInPlace(whole, to: lockFile) + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: lockFile.path) + if case let .unreadable(why) = h.store.lockEndRecord() { + XCTAssertTrue(why.hasPrefix("it could not be read"), why) + } else { + XCTFail("an unreadable lock file read as \(h.store.lockEndRecord())") + } + XCTAssertNil(h.store.lockContents(), "a start cannot put back what it cannot read") + XCTAssertEqual(h.store.sessionEndRecordedInLock(), ".recovery.lock, which it could not be read (Permission denied), so it may hold this session's end and counts as one") + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: lockFile.path) + XCTAssertEqual(h.store.sessionEndRecordedInLock(), ".recovery.lock") + + try h.store.remove(at: h.home.paths.sessionFile) + XCTAssertNil(h.store.sessionEndRecordedInLock(), "nothing to end without session.json") + + try FileManager.default.removeItem(at: lockFile) + try FileManager.default.createDirectory(at: lockFile, withIntermediateDirectories: false) + XCTAssertEqual(h.store.lockEndRecord(), .none) + XCTAssertEqual(h.store.lockContents(), Data()) + } + + // MARK: Writing + + /// The Store writes and empties the record through the handle of the + /// lock this process holds, in place: the inode stays, the file stays + /// the lock, and its descriptors still pass to a child. Without a held + /// lock nothing is written or emptied. A record of one session.json + /// ends no other. + func testTheStoreWritesAndEmptiesTheRecordInPlaceOnlyThroughTheHeldLock() throws { + let lock = RecoveryLock(url: lockFile) + let held = try XCTUnwrap(try lock.tryAcquire()) + defer { held.release() } + let lockInode = try inode(lockFile) + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + + XCTAssertFalse(h.store.recordSessionEndInLock(lock: nil)) + XCTAssertEqual(lockBytes(), Data(), "nothing is written without the lock") + XCTAssertTrue(h.store.recordSessionEndInLock(lock: held)) + XCTAssertEqual(lockBytes(), record(of: bytes)) + XCTAssertEqual(try inode(lockFile), lockInode) + XCTAssertEqual(h.store.sessionEndRecordedInLock(), ".recovery.lock") + XCTAssertTrue(h.store.recordSessionEndInLock(lock: nil), "a record already there is used again") + XCTAssertNil(try lock.tryAcquire(), "the file is still the lock") + let child = try XCTUnwrap(held.descriptorForChild()) + var onChild = stat() + XCTAssertEqual(fstat(child, &onChild), 0) + XCTAssertEqual(UInt64(onChild.st_ino), lockInode) + close(child) + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(1200))) + XCTAssertNil(h.store.sessionEndRecordedInLock(), "a record of other bytes ends nothing") + XCTAssertFalse(h.store.clearLockEndRecord(lock: nil)) + XCTAssertEqual(lockBytes(), record(of: bytes), "nothing is emptied without the lock") + XCTAssertTrue(h.store.clearLockEndRecord(lock: held)) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + + // Shorter content over longer is cut to its length. + XCTAssertTrue(held.replaceContents(with: Data(repeating: 0x41, count: 4096), at: lockFile.path)) + XCTAssertTrue(held.replaceContents(with: Data("short\n".utf8), at: lockFile.path)) + XCTAssertEqual(lockBytes(), Data("short\n".utf8)) + XCTAssertTrue(h.store.restoreLockContents(Data(), lock: held)) + XCTAssertEqual(lockBytes(), Data()) + + // A record goes over content that is no record. A file that cannot + // be read counts as the end already and is never written over: what + // it holds is unknown, and a write over it could leave fewer bytes + // of a whole record than it held. The end goes on to the log. + let current = try Data(contentsOf: h.home.paths.sessionFile) + try writeInPlace(Data("pid 4242\n".utf8), to: lockFile) + XCTAssertTrue(h.store.recordSessionEndInLock(lock: held)) + XCTAssertEqual(lockBytes(), record(of: current)) + try writeInPlace(record(of: bytes), to: lockFile) + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: lockFile.path) + XCTAssertNotNil(h.store.sessionEndRecordedInLock(), "a file that cannot be read counts as the end") + XCTAssertFalse(h.store.recordSessionEndInLock(lock: held), "nor is it written over") + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: lockFile.path) + XCTAssertEqual(lockBytes(), record(of: bytes), "left as it was") + XCTAssertTrue(h.store.clearLockEndRecord(lock: held)) + XCTAssertEqual(try inode(lockFile), lockInode) + + held.release() + XCTAssertFalse(held.replaceContents(with: Data("x".utf8), at: lockFile.path), "a released handle writes nothing") + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// A write in place that writes no byte ends the attempt instead of + /// being tried again forever: `replaceContents` returns false after + /// that one write, and the file keeps the bytes it shared with the + /// start of the new content. + func testAWriteInPlaceThatWritesNothingEndsTheAttempt() throws { + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + XCTAssertTrue(held.replaceContents(with: Data("same start, old end".utf8), at: lockFile.path)) + let calls = Locked(0) + RecoveryLockHandle.pwriteForTesting = { _, _, _, _ in + calls.value += 1 + return 0 + } + defer { RecoveryLockHandle.pwriteForTesting = nil } + + XCTAssertFalse(held.replaceContents(with: Data("same start, new end".utf8), at: lockFile.path)) + XCTAssertEqual(calls.value, 1) + XCTAssertEqual(lockBytes(), Data("same start, ".utf8)) + } + + /// Every state a writer leaves when it stops partway through this + /// session's record, over old content of each kind and length it can + /// find there. The app keeps the bytes the file shares with the start + /// of the record (p of them), cuts the file to them, then appends the + /// rest (`RecoveryLockHandle.replaceContents`). A file size limit of k + /// bytes stops its real write, for every k up to all but one byte and + /// each length and shared part around it: below p the cut itself fails + /// (macOS refuses ftruncate to a length past the limit), so the file is + /// unchanged; from p on, it holds the record's first k bytes, nothing + /// at all when p and k are 0. A stop between the cut and the append, or + /// in the append, leaves the record's first j bytes for j from p on, + /// which the test writes itself, as it does the agent's states. The + /// agent leaves content that already counts as the end as it is or + /// appends the rest to the record's first bytes, and empties anything + /// else with `>` before it writes (record_end_in_lock). Each state is + /// one of three, told apart here: the old bytes unchanged, which count + /// exactly as they did; an empty file, the zero bytes before a first + /// byte, reached only from content that shares no first byte with the + /// record and so did not count; or the record's first bytes, a partial + /// record, which counts. So no state counts for less than the content + /// found. The app's writer then completes the record from each start in + /// place. + func testAWriterStoppedPartwayNeverLeavesLessOfThisEndThanItFound() throws { + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + let lockInode = try inode(lockFile) + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + let other = record(of: Data("an earlier session.json".utf8)) + let tagged = Store.lockEndRecordTag.utf8.count + 1 + let starts: [(name: String, content: Data, counts: Bool)] = [ + ("empty", Data(), false), + ("one byte of other text", Data("p".utf8), false), + ("other text", Data("pid 4242\n".utf8), false), + ("other text as long as the record", Data(repeating: 0x41, count: whole.count), false), + ("more bytes than the record", Data(repeating: 0x41, count: whole.count * 3), false), + ("another session's record", other, false), + ("another session's record cut short", other.prefix(whole.count / 2), false), + ("this record's first byte", whole.prefix(1), true), + ("this record's tag", whole.prefix(tagged), true), + ("this record's first bytes", whole.prefix(whole.count / 2), true), + ("this record but its last byte", whole.prefix(whole.count - 1), true), + ("this record's first bytes, then another byte", whole.prefix(whole.count / 2) + Data("x".utf8), false), + ("this record and old bytes", whole + Data(repeating: 0x41, count: 64), true), + ] + func counts(_ content: Data) throws -> Bool { + try writeInPlace(content, to: lockFile) + return h.store.sessionEndRecordedInLock() != nil + } + for (name, start, counted) in starts { + XCTAssertEqual(try counts(start), counted, name) + let p = zip(start, whole).prefix { $0 == $1 }.count + let ks = Set([0, 1, 2, p - 1, p, p + 1, tagged, whole.count / 3, whole.count / 2, whole.count - 1, start.count, start.count + 1]) + .filter { (0.. 0, "\(name): the app stopped after \(j) bytes") + XCTAssertTrue(j > 0 || !counted, name) + } + // The agent: content that counts is left, or its first bytes + // grow; anything else is emptied, then written. + if counted { + let agentStates: [Data] = start.count < whole.count + ? Set([start.count, start.count + 1, whole.count - 1]).filter { (start.count.. and its write") + for j in [1, tagged, whole.count / 2, whole.count - 1] { + XCTAssertTrue(try counts(whole.prefix(j)), "\(name): the agent's first \(j) bytes") + } + } + try writeInPlace(start, to: lockFile) + XCTAssertTrue(h.store.recordSessionEndInLock(lock: held), name) + XCTAssertEqual(lockBytes(), whole, name) + XCTAssertEqual(try inode(lockFile), lockInode, name) + } + } + + /// A symlink at the lock path is never read or written as a record, + /// even when the file it points to holds a matching one and the lock + /// was taken through it. A file the handle does not hold is not + /// written either. + func testASymlinkOrAnotherFileAtTheLockPathIsNeverReadOrWrittenAsARecord() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + let target = h.home.root.appendingPathComponent("elsewhere.lock") + try record(of: bytes).write(to: target) + try? FileManager.default.removeItem(at: lockFile) + try FileManager.default.createSymbolicLink(at: lockFile, withDestinationURL: target) + + XCTAssertEqual(h.store.lockEndRecord(), .none) + XCTAssertNil(h.store.sessionEndRecordedInLock()) + // The handle's descriptor is open on the target, so anything + // written or emptied through it would show there. + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + var onHandle = stat() + let child = try XCTUnwrap(held.descriptorForChild()) + XCTAssertEqual(fstat(child, &onHandle), 0) + close(child) + XCTAssertEqual(UInt64(onHandle.st_ino), try inode(target)) + XCTAssertFalse(h.store.recordSessionEndInLock(lock: held)) + XCTAssertTrue(h.store.clearLockEndRecord(lock: held), "a symlink holds no record to empty") + XCTAssertFalse(held.replaceContents(with: Data("x".utf8), at: lockFile.path)) + XCTAssertFalse(h.store.restoreLockContents(Data("x".utf8), lock: held)) + XCTAssertEqual(try Data(contentsOf: target), record(of: bytes), "nothing written or emptied through the symlink") + + // The path now names a regular file the handle does not hold. + try FileManager.default.removeItem(at: lockFile) + try Data().write(to: lockFile) + XCTAssertFalse(h.store.recordSessionEndInLock(lock: held)) + XCTAssertEqual(lockBytes(), Data()) + } + + // MARK: The app's end + + /// The app ends a session whose files take no record and neither + /// folder takes a new file: the end is recorded in the lock file, in + /// place, before anything is undone. After every file and both folders + /// are repaired, a relaunch with SleepDisabled still 1 ends it instead + /// of holding sleep again, removes session.json and empties the lock + /// file, which keeps its inode throughout. + func testAnAppEndRecordedOnlyInTheLockFileIsNotResumedAfterAFullRepair() async throws { + let m = try await startThenPinAll() + let lockInode = try inode(lockFile) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + try acls.denyNewFiles(in: h.home.paths.appSupport) + try acls.denyNewFiles(in: h.home.paths.logs) + + let outcome = await m.end(reason: .user) + + XCTAssertEqual(outcome, .sessionRetained) + XCTAssertEqual(lockBytes(), record(of: bytes)) + XCTAssertEqual(try inode(lockFile), lockInode) + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertTrue(logText().contains("could not remove session.json: "), logText()) + XCTAssertTrue(logText().contains("; its end is recorded in the recovery lock file .recovery.lock"), logText()) + XCTAssertTrue(h.notifier.posts.last?.body.contains("its end is recorded, so a relaunch will not resume it") == true, "\(h.notifier.posts)") + + try acls.removeAll(h.home.paths.appSupport) + try acls.removeAll(h.home.paths.logs) + unpinAll() + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertTrue(logText().contains(endedInLockLine), logText()) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// An ordinary end leaves the lock file empty, and so does an end that + /// cannot remove session.json while the journal takes the record: the + /// lock file is only the last place. + func testAnOrdinaryEndAndAWritableJournalLeaveTheLockFileEmpty() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + let lockInode = try inode(lockFile) + _ = await m.end(reason: .user) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(lockBytes(), Data()) + + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + try setImmutable(h.home.paths.sessionFile, true) + try setImmutable(h.home.paths.endedSessionFile, true) + _ = await m.end(reason: .user) + XCTAssertTrue(logText().contains("its end is recorded in state.json"), logText()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// The app's end stopped partway through its write to the lock file, + /// with session.json still in place and SleepDisabled still 1: the + /// record's first bytes, or the whole record with the file's old bytes + /// after it. A relaunch ends the session instead of holding sleep + /// again, removes session.json and empties the lock file in place. A + /// new session then starts, and a crash during it resumes as usual. + func testARelaunchEndsTheSessionWhoseRecordInTheLockFileWasCutShort() async throws { + await h.makeManager().reconcile() + let lockInode = try inode(lockFile) + for form in ["first bytes", "record and old bytes"] { + h.clock.advance(60) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, form) + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + try writeInPlace(form == "first bytes" ? whole.prefix(whole.count / 2) : whole + Data(repeating: 0x41, count: 64), to: lockFile) + XCTAssertTrue(h.guardFake.sleepDisabled, form) + + var before = h.guardFake.calls.count + let mark = logText().count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive, "\(form): \(logText(since: mark))") + XCTAssertFalse(sleepHeldAgain(since: before), "\(form): \(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled, form) + XCTAssertTrue(logText(since: mark).contains("reconcile: session.json holds a session already ended (recorded in .recovery.lock, which holds this session's end record cut short, so it counts as one); restoring, not resuming"), logText(since: mark)) + XCTAssertNil(try h.store.loadSession(), form) + XCTAssertEqual(lockBytes(), Data(), form) + XCTAssertEqual(try inode(lockFile), lockInode, form) + + await next.start(duration: 3600) + XCTAssertTrue(next.isActive, form) + before = h.guardFake.calls.count + let resumed = h.makeManager() + await resumed.reconcile() + XCTAssertTrue(resumed.isActive, "\(form): \(logText(since: mark))") + XCTAssertTrue(sleepHeldAgain(since: before), form) + XCTAssertEqual(lockBytes(), Data(), form) + _ = await resumed.end(reason: .user) + XCTAssertNil(try h.store.loadSession(), form) + } + } + + /// A session resumed after a crash over a lock file holding bytes that + /// end nothing (other text, another session's record): the resume + /// empties the file. Its end then cannot remove session.json and stops + /// partway through its write to the lock file (a file size limit of k + /// bytes), which leaves the record's first k bytes: from the first one + /// they count as the end. So a relaunch from those files with + /// SleepDisabled still 1 ends the session without holding sleep again, + /// and so does the agent, the app still running. A write stopped before + /// its first byte (k = 0) leaves the file empty, which records nothing: + /// a relaunch resumes the session and the agent keeps it, the open + /// residual of an end that reached no file (docs/spec.md). For k = 1 + /// ("e", the first byte of every record) the agent's cleanup cannot + /// empty the file once session.json is gone (its fake rm makes the file + /// append-only): the "e" stays, the app cannot open the lock file, so + /// a relaunch and a start change nothing, and once the file is repaired + /// a start empties it first, so the agent's next run keeps the new + /// session, a crash resumes it and its end empties the file. + func testAnEndStoppedPartwayAfterAResumeEndsTheSessionFromItsFirstByte() async throws { + await h.makeManager().reconcile() + let lockInode = try inode(lockFile) + defer { try? FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) } + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(3600))) + let count = record(of: try Data(contentsOf: h.home.paths.sessionFile)).count + try h.store.deleteSession() + for (index, k) in [0, 1, 17, count / 2, count - 1].enumerated() { + h.clock.advance(60) + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, "\(k)") + try writeInPlace(index % 2 == 0 ? Data("pid 4242\n".utf8) : record(of: Data("an earlier session.json".utf8)), to: lockFile) + var before = h.guardFake.calls.count + let resumed = h.makeManager() + await resumed.reconcile() + XCTAssertTrue(resumed.isActive, "\(k): \(logText())") + XCTAssertTrue(sleepHeldAgain(since: before), "\(k)") + XCTAssertEqual(lockBytes(), Data(), "\(k): the resume empties it") + + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + XCTAssertEqual(whole.count, count) + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + XCTAssertFalse(try withFileSizeLimit(k) { h.store.recordSessionEndInLock(lock: held) }, "\(k)") + held.release() + XCTAssertEqual(lockBytes(), whole.prefix(k), "\(k)") + XCTAssertEqual(try inode(lockFile), lockInode, "\(k)") + let crashed = diskCopy() + + XCTAssertTrue(h.guardFake.sleepDisabled, "\(k)") + before = h.guardFake.calls.count + var mark = logText().count + let next = h.makeManager() + await next.reconcile() + if k == 0 { + XCTAssertTrue(next.isActive, "the residual: \(logText(since: mark))") + XCTAssertTrue(sleepHeldAgain(since: before)) + let run = try await runAgentBesideTheApp() + XCTAssertEqual(run.status, 0, run.log) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertNotNil(try h.store.loadSession()) + _ = await next.end(reason: .user) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(lockBytes(), Data()) + continue + } + XCTAssertFalse(next.isActive, "\(k): \(logText(since: mark))") + XCTAssertFalse(sleepHeldAgain(since: before), "\(k): \(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled, "\(k)") + XCTAssertTrue(logText(since: mark).contains("reconcile: session.json holds a session already ended (recorded in .recovery.lock, which holds this session's end record cut short, so it counts as one); restoring, not resuming"), logText(since: mark)) + XCTAssertNil(try h.store.loadSession(), "\(k)") + XCTAssertEqual(lockBytes(), Data(), "\(k)") + XCTAssertEqual(try inode(lockFile), lockInode, "\(k)") + + try putBack(crashed) + h.guardFake.sleepDisabled = true + let cleanupFails = k == 1 + let caseAgent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent-\(k)", isDirectory: true)) + if cleanupFails { try caseAgent.makeLockAppendOnly(whenRemoving: h.home.paths.sessionFile, lock: lockFile) } + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + mark = logText().count + _ = try await caseAgent.run() + alive.release() + XCTAssertTrue(logText(since: mark).contains(cutShortLine), "\(k): \(logText(since: mark))") + XCTAssertFalse(caseAgent.calls.contains("pmset -g batt"), "\(k)") + XCTAssertTrue(caseAgent.calls.contains(caseAgent.restoreCall), "\(k): \(caseAgent.calls)") + XCTAssertNil(try h.store.loadSession(), "\(k)") + XCTAssertEqual(try inode(lockFile), lockInode, "\(k)") + guard cleanupFails else { + XCTAssertEqual(lockBytes(), Data(), "\(k)") + continue + } + + // The agent's cleanup could not empty the "e". + XCTAssertEqual(lockBytes(), whole.prefix(1)) + XCTAssertTrue(logText(since: mark).contains("could not empty \(lockFile.path) of content that ends no session"), logText(since: mark)) + let refused = diskCopy() + h.guardFake.sleepDisabled = false + before = h.guardFake.calls.count + mark = logText().count + let blocked = h.makeManager() + await blocked.reconcile() + await blocked.start(duration: 3600) + XCTAssertFalse(blocked.isActive, logText(since: mark)) + XCTAssertFalse(sleepHeldAgain(since: before)) + XCTAssertTrue(logText(since: mark).contains("start skipped, nothing changed: could not open recovery lock \(lockFile.path)"), logText(since: mark)) + XCTAssertEqual(diskCopy(), refused, "nothing changed") + + try FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) + await blocked.start(duration: 3600) + XCTAssertTrue(blocked.isActive, logText(since: mark)) + XCTAssertEqual(lockBytes(), Data()) + let kept = try await runAgentBesideTheApp() + XCTAssertEqual(kept.status, 0, kept.log) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertNotNil(try h.store.loadSession()) + before = h.guardFake.calls.count + let again = h.makeManager() + await again.reconcile() + XCTAssertTrue(again.isActive, logText(since: mark)) + XCTAssertTrue(sleepHeldAgain(since: before)) + _ = await again.end(reason: .user) + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + } + + // MARK: The agent's end + + /// The reviewer's case: session.json, an unrelated ended-session.json + /// and state.json immutable, neither folder takes a new file, and the + /// restore fails. The agent records the end in the lock file before the + /// undo and keeps the inode. After every flag and both ACLs are + /// repaired, the app launches first with SleepDisabled still 1: it ends + /// the session instead of holding sleep again, removes session.json + /// and empties the lock file. Neither folder takes the run's status + /// files either, so it waits for a status that never comes; the fake + /// commands get a 2 s limit instead of 30 s to shorten that wait. + func testAnAgentEndRecordedOnlyInTheLockFileIsNotRevivedAfterAFullRepair() async throws { + _ = try await startThenPinAll() + try agent.setCommandTimeout(2) + let lockInode = try inode(lockFile) + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + try agent.failSudo() + try acls.denyNewFiles(in: h.home.paths.appSupport) + try acls.denyNewFiles(in: h.home.paths.logs) + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(lockBytes(), record(of: bytes)) + XCTAssertEqual(try inode(lockFile), lockInode) + XCTAssertTrue(logText().contains("its end is recorded in the recovery lock file \(lockFile.path) instead"), logText()) + + try acls.removeAll(h.home.paths.appSupport) + try acls.removeAll(h.home.paths.logs) + unpinAll() + XCTAssertTrue(h.guardFake.sleepDisabled, "the failed restore left it at 1") + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLockLine), logText()) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// The same end with a restore that works and no record aside possible + /// (MKTEMP fails): sleep is restored, then something else sets + /// SleepDisabled to 1 again. After the repair, the app does not hold + /// sleep again for the session: the lock file says it ended. + func testAForeignHoldAfterTheAgentsRestoreDoesNotReviveASessionEndedInTheLockFile() async throws { + _ = try await startThenPinAll() + let lockInode = try inode(lockFile) + try agent.refuseRecordsAside() + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + XCTAssertEqual(lockBytes(), record(of: try Data(contentsOf: h.home.paths.sessionFile))) + h.guardFake.sleepDisabled = false + + unpinAll() + h.guardFake.sleepDisabled = true + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLockLine), logText()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// The app still running when the agent ends its session that way: the + /// next tick finds the end in the lock file and ends the session too. + func testTheTickEndsASessionTheAgentEndedInTheLockFile() async throws { + let m = try await startThenPinAll() + try agent.refuseRecordsAside() + try await runAgent(expecting: 1) + XCTAssertNotNil(h.store.sessionEndRecordedInLock()) + + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive) + XCTAssertTrue(logText().contains("is recorded as ended in .recovery.lock: the recovery agent ended it"), logText()) + } + + /// The agent writes the record but cannot read it back (CAT fails), and + /// insomnia.log takes none (`refuseLogRecord`): it does not count it as + /// recorded, logs that nothing took the record and keeps the sleep + /// entry. Its next run reads the file as unreadable, + /// which counts as the end. The app reads the whole record and does not + /// resume the session after a repair. + func testARecordTheAgentCannotReadBackStillEndsTheSession() async throws { + _ = try await startThenPinAll() + let bytes = try Data(contentsOf: h.home.paths.sessionFile) + try agent.refuseRecordsAside() + try agent.failLockReadBack() + try agent.refuseLogRecord() + + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("the recovery lock file \(lockFile.path), or the log file \(h.home.paths.logFile.path). Sleep is restored anyway"), logText()) + XCTAssertEqual(lockBytes(), record(of: bytes), "written, though not read back") + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try agent.clearCalls() + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(lockFile.path), which it could not be read, so it may hold this session's end and counts as one)"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt")) + + unpinAll() + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLockLine), logText()) + } + + /// The agent finds this session's record cut short in the lock file, as + /// a run or the app leaves it when stopped partway, while session.json, + /// an unrelated ended-session.json and state.json are immutable, no + /// record aside can be made and the restore fails. It counts as the + /// end, and the agent never empties it: + /// - its first bytes: the agent appends the rest. The fake rm sets the + /// append-only flag on the lock file (chflags uappnd) when the run + /// tries to remove session.json, after the run opened its fd 9 there, + /// so from then on a write with `>`, which empties the file first, + /// fails and only an append goes through. + /// - the whole record with old bytes after it: the agent leaves it as + /// it is and records the end in insomnia.log. + /// A lock file the agent cannot read (CAT fails on it) counts as the + /// end too, and the agent leaves it as it is as well. After the repair + /// the app relaunches with SleepDisabled still 1, ends the session + /// instead of resuming it, removes session.json and empties the lock + /// file, which keeps its inode throughout. + func testTheAgentNeverEmptiesThisSessionsRecordCutShort() async throws { + await h.makeManager().reconcile() + let lockInode = try inode(lockFile) + defer { try? FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) } + for (index, form) in ["first bytes", "record and old bytes", "unreadable"].enumerated() { + h.clock.advance(60) + _ = try await startThenPinAll() + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + let found = form == "first bytes" ? whole.prefix(whole.count / 2) + : form == "record and old bytes" ? whole + Data(repeating: 0x41, count: 64) + : Data("pid 4242\n".utf8) + try writeInPlace(found, to: lockFile) + let caseAgent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent-\(index)", isDirectory: true)) + try caseAgent.refuseRecordsAside() + try caseAgent.failSudo() + if form == "first bytes" { + try caseAgent.makeLockAppendOnly(whenRemoving: h.home.paths.sessionFile, lock: lockFile) + } else if form == "unreadable" { + try caseAgent.failLockReadBack() + } + + var mark = logText().count + let exit = try await caseAgent.run() + let log = logText(since: mark) + XCTAssertEqual(exit, 1, "\(form): \(log)") + XCTAssertFalse(caseAgent.calls.contains("pmset -g batt"), form) + if form == "first bytes" { + XCTAssertTrue(log.contains("already ended (recorded in \(lockFile.path), which holds this session's end record cut short, so it counts as one)"), log) + XCTAssertTrue(log.contains("its end is recorded in the recovery lock file \(lockFile.path) instead"), log) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: lockFile.path)[.appendOnly] as? Bool, true, "the fake rm ran") + XCTAssertEqual(lockBytes(), whole) + try FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) + } else { + let why = form == "unreadable" ? "which it could not be read, so it may hold this session's end and counts as one" + : "which holds this session's end record cut short, so it counts as one" + XCTAssertTrue(log.contains("already ended (recorded in \(lockFile.path), \(why))"), log) + XCTAssertTrue(log.contains("its end is recorded in the log file \(h.home.paths.logFile.path) instead"), log) + XCTAssertEqual(lockBytes(), found, form) + } + XCTAssertEqual(try inode(lockFile), lockInode, form) + + unpinAll() + XCTAssertTrue(h.guardFake.sleepDisabled, "\(form): the failed restore left it at 1") + let before = h.guardFake.calls.count + mark = logText().count + let next = h.makeManager() + await next.reconcile() + let recordedIn = form == "first bytes" ? ".recovery.lock" + : form == "record and old bytes" ? ".recovery.lock, which holds this session's end record cut short, so it counts as one" + : "insomnia.log" + XCTAssertFalse(next.isActive, "\(form): \(logText(since: mark))") + XCTAssertFalse(sleepHeldAgain(since: before), "\(form): \(h.guardFake.calls)") + XCTAssertTrue(logText(since: mark).contains("reconcile: session.json holds a session already ended (recorded in \(recordedIn)); restoring, not resuming"), logText(since: mark)) + XCTAssertNil(try h.store.loadSession(), form) + XCTAssertEqual(lockBytes(), Data(), form) + XCTAssertEqual(try inode(lockFile), lockInode, form) + } + } + + // MARK: Reads that fail + + /// A read of the lock file that fails is tried again, up to + /// `Store.lockReadAttempts` reads in all (`Store.lockReadErrnoForTesting` + /// with `lockReadFailuresForTesting`). A crash whose lock file holds + /// content that is no record, read on the last try, resumes, and the + /// resume empties the file. A file that fails every try still counts as + /// the end, as it may hold one. A whole record of the session in + /// insomnia.log is named then instead, since it shows the end that the + /// file only may hold. + func testTheAppReadsALockFileAgainBeforeItCountsAsTheEnd() async throws { + let first = h.makeManager() + await first.reconcile() + await first.start(duration: 3600) + let lockInode = try inode(lockFile) + let foreign = Data("pid 4242\n".utf8) + try writeInPlace(foreign, to: lockFile) + Store.lockReadErrnoForTesting = EIO + Store.lockReadFailuresForTesting = Store.lockReadAttempts - 1 + var before = h.guardFake.calls.count + var mark = logText().count + let resumed = h.makeManager() + await resumed.reconcile() + XCTAssertTrue(resumed.isActive, logText(since: mark)) + XCTAssertTrue(sleepHeldAgain(since: before)) + XCTAssertEqual(Store.lockReadFailuresForTesting, 0, "every failed read was tried again") + XCTAssertFalse(logText(since: mark).contains("recorded in .recovery.lock"), logText(since: mark)) + XCTAssertEqual(lockBytes(), Data(), "the resume empties it") + + try writeInPlace(foreign, to: lockFile) + Store.lockReadFailuresForTesting = Store.lockReadAttempts + before = h.guardFake.calls.count + mark = logText().count + let ended = h.makeManager() + await ended.reconcile() + XCTAssertFalse(ended.isActive, logText(since: mark)) + XCTAssertFalse(sleepHeldAgain(since: before)) + XCTAssertTrue(logText(since: mark).contains("reconcile: session.json holds a session already ended (recorded in .recovery.lock, which it could not be read (Input/output error), so it may hold this session's end and counts as one); restoring, not resuming"), logText(since: mark)) + XCTAssertNil(try h.store.loadSession()) + + Store.lockReadErrnoForTesting = nil + Store.lockReadFailuresForTesting = nil + let second = h.makeManager() + await second.reconcile() + await second.start(duration: 3600) + XCTAssertTrue(second.isActive, logText()) + let line = try XCTUnwrap(LogEndRecord.line(for: try Data(contentsOf: h.home.paths.sessionFile))) + let log = try FileHandle(forWritingTo: h.home.paths.logFile) + try log.seekToEnd() + try log.write(contentsOf: line + Data("\n".utf8)) + try log.close() + try writeInPlace(foreign, to: lockFile) + Store.lockReadErrnoForTesting = EIO + before = h.guardFake.calls.count + mark = logText().count + let named = h.makeManager() + await named.reconcile() + Store.lockReadErrnoForTesting = nil + XCTAssertFalse(named.isActive, logText(since: mark)) + XCTAssertFalse(sleepHeldAgain(since: before)) + XCTAssertTrue(logText(since: mark).contains("reconcile: session.json holds a session already ended (recorded in insomnia.log); restoring, not resuming"), logText(since: mark)) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// The agent reads the lock file again too, LOCK_READ_ATTEMPTS reads + /// LOCK_READ_RETRY_SECONDS apart (the app's two values), while it cannot + /// read it (CAT fails, `failLockReadBack(times:)`). Content that is no + /// record, read on the last try, ends nothing: the agent empties it and + /// checks the session as usual. When every read fails the file counts + /// as the end, and a whole record of the session in insomnia.log is + /// named then instead. + func testTheAgentReadsALockFileAgainBeforeItCountsAsTheEnd() async throws { + let text = try String(contentsOf: agent.script, encoding: .utf8).components(separatedBy: "\n") + XCTAssertEqual(text.filter { $0.hasPrefix("LOCK_READ_ATTEMPTS=") }, ["LOCK_READ_ATTEMPTS=\(Store.lockReadAttempts)"]) + XCTAssertEqual(Store.lockReadRetryMicroseconds, 100_000) + XCTAssertEqual(text.filter { $0.hasPrefix("LOCK_READ_RETRY_SECONDS=") }, ["LOCK_READ_RETRY_SECONDS=0.1"]) + + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let lockInode = try inode(lockFile) + let foreign = Data("pid 4242\n".utf8) + try writeInPlace(foreign, to: lockFile) + try agent.failLockReadBack(times: Store.lockReadAttempts - 1) + var (status, log) = try await runAgentBesideTheApp() + XCTAssertEqual(status, 0, log) + XCTAssertEqual(try agent.lockReads(), Store.lockReadAttempts, "the failed reads were tried again") + XCTAssertNotNil(try h.store.loadSession(), log) + XCTAssertTrue(agent.calls.contains("pmset -g batt"), log) + XCTAssertFalse(log.contains("already ended"), log) + XCTAssertTrue(log.contains("emptying \(lockFile.path): it holds bytes other than one whole end record, which ends no session"), log) + XCTAssertEqual(lockBytes(), Data()) + + let line = try XCTUnwrap(LogEndRecord.line(for: try Data(contentsOf: h.home.paths.sessionFile))) + let handle = try FileHandle(forWritingTo: h.home.paths.logFile) + try handle.seekToEnd() + try handle.write(contentsOf: line + Data("\n".utf8)) + try handle.close() + try writeInPlace(foreign, to: lockFile) + let failing = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent-failing", isDirectory: true)) + try failing.failLockReadBack() + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + let mark = logText().count + status = try await failing.run() + alive.release() + log = logText(since: mark) + XCTAssertEqual(status, 0, log) + XCTAssertFalse(failing.calls.contains("pmset -g batt"), log) + XCTAssertTrue(log.contains("already ended (recorded in \(h.home.paths.logFile.path))"), log) + XCTAssertFalse(log.contains("already ended (recorded in \(lockFile.path)"), log) + XCTAssertGreaterThanOrEqual(try failing.lockReads(), Store.lockReadAttempts) + XCTAssertNil(try h.store.loadSession(), log) + XCTAssertEqual(lockBytes(), Data(), "emptied once session.json is gone, when it ends nothing") + XCTAssertEqual(try inode(lockFile), lockInode) + } + + // MARK: Other sessions and cleanup + + /// A crash with the lock file empty, with a whole record of an earlier + /// session's bytes, and with content that is no whole record (another + /// session's record cut short) resumes: none of them ends this session, + /// and the resume empties the file. The same content written after the + /// resume, while the app runs, ends nothing for the agent either: it + /// empties it and checks the session as usual. + func testAStaleRecordOrContentThatIsNoRecordEndsNoNewerSession() async throws { + let first = h.makeManager() + await first.reconcile() + await first.start(duration: 3600) + let lockInode = try inode(lockFile) + + var before = h.guardFake.calls.count + let crashed = h.makeManager() + await crashed.reconcile() + XCTAssertTrue(crashed.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before)) + + let stale = record(of: Data("an earlier session.json".utf8)) + try writeInPlace(stale, to: lockFile) + before = h.guardFake.calls.count + let again = h.makeManager() + await again.reconcile() + XCTAssertTrue(again.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before)) + XCTAssertEqual(lockBytes(), Data(), "the resume empties it") + XCTAssertEqual(try inode(lockFile), lockInode) + try writeInPlace(stale, to: lockFile) + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + + let cut = Data("\(Store.lockEndRecordTag) QUJ".utf8) + try writeInPlace(cut, to: lockFile) + before = h.guardFake.calls.count + let partial = h.makeManager() + await partial.reconcile() + XCTAssertTrue(partial.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before)) + XCTAssertFalse(logText().contains("recorded in .recovery.lock"), logText()) + XCTAssertEqual(lockBytes(), Data(), "the resume empties it") + try writeInPlace(cut, to: lockFile) + + try agent.clearCalls() + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertTrue(logText().contains("emptying \(lockFile.path): it holds bytes other than one whole end record, which ends no session"), logText()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// A start empties whatever the lock file held before (it ended no + /// session.json once the new one is written), and a start that fails + /// puts back the session.json it replaced together with the record of + /// its end. A lock file over the bound holds no record: it neither + /// refuses a start over a session.json nor goes back with it. (A lock + /// file that cannot be read refuses such a start: + /// testALockFileThatCannotBeReadIsNeverWrittenOverOrReplacedWithASession.) + func testAStartEmptiesTheRecordAndARollbackPutsItBack() async throws { + let m = h.makeManager() + await m.reconcile() + try writeInPlace(Data("left over".utf8), to: lockFile) + let lockInode = try inode(lockFile) + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertEqual(lockBytes(), Data()) + _ = await m.end(reason: .user) + + let earlier = #"{"endsAt":"2027-01-15T08:30:00Z","extensions":[],"startedAt":"2027-01-15T08:00:00Z"}"# + try Data(earlier.utf8).write(to: h.home.paths.sessionFile) + let ended = record(of: Data(earlier.utf8)) + try writeInPlace(ended, to: lockFile) + h.backstop.failArm = true + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + XCTAssertEqual(try Data(contentsOf: h.home.paths.sessionFile), Data(earlier.utf8)) + XCTAssertEqual(lockBytes(), ended, "the record goes back with the file it ends") + XCTAssertEqual(try inode(lockFile), lockInode) + + try writeInPlace(Data(repeating: 0x41, count: Store.lockEndRecordMaxBytes + 1), to: lockFile) + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + XCTAssertFalse(logText().contains("start refused, nothing changed"), logText()) + XCTAssertEqual(try Data(contentsOf: h.home.paths.sessionFile), Data(earlier.utf8)) + XCTAssertEqual(lockBytes(), Data(), "content that ends nothing is not put back") + + h.backstop.failArm = false + + try writeInPlace(ended, to: lockFile) + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertNotEqual(try Data(contentsOf: h.home.paths.sessionFile), Data(earlier.utf8)) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } + + /// A start over bytes in the lock file that end no session.json, as a + /// cleanup that could not empty them leaves them: a stale record's + /// first byte ("e", the first byte of every record, the new session's + /// too), another session's record, other text. The start empties them + /// before it writes session.json, so at each step after that the file + /// holds nothing: a crash just after session.json is written resumes, + /// and the agent then checks the session as usual. The same "e" written + /// once the session runs ends it. A start over an earlier session.json + /// whose record the lock file holds cut short completes that record (or + /// keeps it with the old bytes after it) before it writes the new file + /// and keeps it until its last step, then empties it. A lock file the + /// start cannot settle (made append-only once the start has read it, + /// so it is neither cut nor written past its end without O_APPEND) + /// rolls the start back with nothing changed; once repaired, the start + /// goes through. + func testAStartLeavesNothingInTheLockFileThatEndsItsOwnSession() async throws { + await h.makeManager().reconcile() + let lockInode = try inode(lockFile) + var copies: [SessionManager.StartStep: DiskCopy] = [:] + func manager() async -> SessionManager { + let m = h.makeManager() + await m.reconcile() + m.onStartStepForTesting = { step in copies[step] = self.diskCopy() } + return m + } + func resumesFrom(_ copy: DiskCopy?, _ name: String) async throws { + try putBack(try XCTUnwrap(copy, name)) + h.guardFake.sleepDisabled = false + let before = h.guardFake.calls.count + let mark = logText().count + let relaunched = h.makeManager() + await relaunched.reconcile() + XCTAssertTrue(relaunched.isActive, "\(name): \(logText(since: mark))") + XCTAssertTrue(sleepHeldAgain(since: before), name) + XCTAssertEqual(lockBytes(), Data(), name) + let run = try await runAgentBesideTheApp() + XCTAssertEqual(run.status, 0, "\(name): \(run.log)") + XCTAssertTrue(agent.calls.contains("pmset -g batt"), name) + XCTAssertNotNil(try h.store.loadSession(), name) + _ = await relaunched.end(reason: .user) + XCTAssertNil(try h.store.loadSession(), name) + XCTAssertEqual(lockBytes(), Data(), name) + } + + let stale: [(name: String, bytes: Data)] = [ + ("a stale record's first byte", Data("e".utf8)), + ("another session's record", record(of: Data("an earlier session.json".utf8))), + ("other text", Data("pid 4242\n".utf8)), + ] + for (name, bytes) in stale { + h.clock.advance(60) + let m = await manager() + try writeInPlace(bytes, to: lockFile) + copies = [:] + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, name) + XCTAssertEqual(copies[.sessionWritten]?.lock, Data(), name) + XCTAssertEqual(copies[.journalWritten]?.lock, Data(), name) + XCTAssertNotNil(copies[.sessionWritten]?.session, name) + XCTAssertEqual(lockBytes(), Data(), name) + XCTAssertEqual(try inode(lockFile), lockInode, name) + try await resumesFrom(copies[.sessionWritten], name) + } + + h.clock.advance(60) + let control = await manager() + await control.start(duration: 3600) + try writeInPlace(Data("e".utf8), to: lockFile) + let ended = try await runAgentBesideTheApp() + XCTAssertTrue(ended.log.contains(cutShortLine), ended.log) + XCTAssertNil(try h.store.loadSession(), "the control: the same byte after the start ends it") + try writeInPlace(Data(), to: lockFile) + + let earlierRecord = record(of: earlier) + for found in [earlierRecord.prefix(1), earlierRecord.prefix(earlierRecord.count / 2), earlierRecord + Data(repeating: 0x41, count: 64)] { + let name = "\(found.count) bytes of the earlier session's record" + h.clock.advance(60) + let m = await manager() + try earlier.write(to: h.home.paths.sessionFile) + try writeInPlace(found, to: lockFile) + copies = [:] + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, name) + let whole = found.count > earlierRecord.count ? found : earlierRecord + XCTAssertEqual(copies[.sessionWritten]?.lock, whole, name) + XCTAssertEqual(copies[.journalWritten]?.lock, whole, name) + XCTAssertNotEqual(copies[.sessionWritten]?.session, earlier, name) + XCTAssertEqual(lockBytes(), Data(), name) + XCTAssertEqual(try inode(lockFile), lockInode, name) + try await resumesFrom(copies[.sessionWritten], name) + } + + for previous in [nil, earlier] as [Data?] { + let found = previous == nil ? Data("e".utf8) : earlierRecord.prefix(earlierRecord.count / 2) + let name = previous == nil ? "no session.json" : "an earlier session.json" + h.clock.advance(60) + let m = await manager() + if let previous { try previous.write(to: h.home.paths.sessionFile) } + try writeInPlace(found, to: lockFile) + let before = diskCopy() + let calls = h.guardFake.calls.count + let mark = logText().count + m.onStartStepForTesting = { [lockFile] step in + guard step == .lockFileRead else { return } + try? FileManager.default.setAttributes([.appendOnly: true], ofItemAtPath: lockFile.path) + } + await m.start(duration: 3600) + m.onStartStepForTesting = nil + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: lockFile.path)[.appendOnly] as? Bool, true, name) + try FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) + XCTAssertFalse(m.isActive, name) + XCTAssertTrue(logText(since: mark).contains("could not write session: \(lockFile.path) holds bytes that may record a session's end and could not be rewritten"), logText(since: mark)) + XCTAssertEqual(diskCopy(), before, "\(name): nothing changed") + XCTAssertFalse(sleepHeldAgain(since: calls), name) + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, name) + XCTAssertEqual(lockBytes(), Data(), name) + _ = await m.end(reason: .user) + XCTAssertNil(try h.store.loadSession(), name) + } + } + + /// A start that fails puts back session.json, state.json and the lock + /// file exactly as they were, whatever the lock file held. With no + /// session.json: nothing, or bytes that end nothing ("e", other text, + /// another session's record), which go back once the new session.json + /// is gone. With an earlier session.json: its record whole, cut short + /// or with old bytes after it, which goes back before that file, and + /// other text, which goes back after it. The start fails when the agent + /// cannot be armed, or at its last step, when the lock file cannot be + /// emptied of the earlier session's record (append-only from the + /// journal write on). Neither touches SleepDisabled. At the last step + /// the file holds that record whole and still cannot be cut, so its + /// first bytes cannot go back: the whole record stays, which counts as + /// the earlier session's end as its first bytes did. + func testAFailedStartPutsBackSessionJournalAndLockFileExactly() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + _ = await m.end(reason: .user) + let lockInode = try inode(lockFile) + let ended = record(of: earlier) + let other = Data("pid 4242\n".utf8) + let cases: [(name: String, previous: Data?, lock: Data)] = [ + ("no session.json, nothing", nil, Data()), + ("no session.json, a stale record's first byte", nil, Data("e".utf8)), + ("no session.json, other text", nil, other), + ("no session.json, another session's record", nil, ended), + ("an earlier session.json, nothing", earlier, Data()), + ("its record", earlier, ended), + ("its record's first byte", earlier, ended.prefix(1)), + ("its record's first bytes", earlier, ended.prefix(ended.count / 2)), + ("its record and old bytes", earlier, ended + Data(repeating: 0x41, count: 64)), + ("an earlier session.json, other text", earlier, other), + ] + for (name, previous, lock) in cases { + for failure in ["arm", "last step"] { + let label = "\(name), \(failure)" + h.clock.advance(60) + if let previous { try previous.write(to: h.home.paths.sessionFile) } + try writeInPlace(lock, to: lockFile) + let before = diskCopy() + let calls = h.guardFake.calls.count + let mark = logText().count + if failure == "arm" { + h.backstop.failArm = true + } else { + m.onStartStepForTesting = { [lockFile] step in + guard step == .journalWritten else { return } + try? FileManager.default.setAttributes([.appendOnly: true], ofItemAtPath: lockFile.path) + } + } + await m.start(duration: 3600) + h.backstop.failArm = false + m.onStartStepForTesting = nil + try FileManager.default.setAttributes([.appendOnly: false], ofItemAtPath: lockFile.path) + let counted = previous.map { Store.lockContents(lock, endSessionWithBytes: $0) } ?? false + guard failure == "arm" || counted else { + XCTAssertTrue(m.isActive, "\(label): \(logText(since: mark))") + XCTAssertEqual(lockBytes(), Data(), label) + _ = await m.end(reason: .user) + continue + } + XCTAssertFalse(m.isActive, label) + XCTAssertFalse(sleepHeldAgain(since: calls), label) + let cutShort = failure == "last step" && lock.count < ended.count + XCTAssertEqual(diskCopy(), DiskCopy(session: before.session, state: before.state, lock: cutShort ? ended : before.lock), "\(label): \(logText(since: mark))") + XCTAssertEqual(try inode(lockFile), lockInode, label) + if cutShort { + XCTAssertTrue(logText(since: mark).contains("could not restore \(lockFile.path) after a failed start"), logText(since: mark)) + } + if previous != nil { try FileManager.default.removeItem(at: h.home.paths.sessionFile) } + } + } + } + + /// A lock file that cannot be read (a read error once it is open, set + /// through `Store.lockReadErrnoForTesting`; the app's own lock + /// descriptor still works) may hold the end of the session.json in + /// place. A start over that session.json is refused with nothing + /// changed. With no session.json the file ends nothing, and a start + /// empties it. An end that can neither remove session.json nor record + /// it anywhere before the lock file never writes over it: it records + /// the end in insomnia.log. + func testALockFileThatCannotBeReadIsNeverWrittenOverOrReplacedWithASession() async throws { + let m = h.makeManager() + await m.reconcile() + let lockInode = try inode(lockFile) + try earlier.write(to: h.home.paths.sessionFile) + let found = record(of: earlier).prefix(20) + try writeInPlace(found, to: lockFile) + Store.lockReadErrnoForTesting = EIO + let before = diskCopy() + await m.start(duration: 3600) + XCTAssertFalse(m.isActive) + XCTAssertTrue(logText().contains("start refused, nothing changed: \(lockFile.path) could not be read whole, and it may record the end of the session.json in place"), logText()) + Store.lockReadErrnoForTesting = nil + XCTAssertEqual(diskCopy(), before) + + try FileManager.default.removeItem(at: h.home.paths.sessionFile) + Store.lockReadErrnoForTesting = EIO + await m.start(duration: 3600) + XCTAssertTrue(m.isActive, logText()) + XCTAssertEqual(lockBytes(), Data()) + _ = await m.end(reason: .user) + Store.lockReadErrnoForTesting = nil + XCTAssertNil(try h.store.loadSession()) + + let running = try await startThenPinAll() + let whole = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + let held = whole.prefix(whole.count / 2) + try writeInPlace(held, to: lockFile) + try acls.denyNewFiles(in: h.home.paths.appSupport) + try acls.denyNewFiles(in: h.home.paths.logs) + Store.lockReadErrnoForTesting = EIO + let outcome = await running.end(reason: .user) + Store.lockReadErrnoForTesting = nil + XCTAssertEqual(outcome, .sessionRetained) + XCTAssertEqual(lockBytes(), held, "never written over") + XCTAssertEqual(try inode(lockFile), lockInode) + XCTAssertTrue(logText().contains("; its end is recorded in the log file insomnia.log"), logText()) + XCTAssertNotNil(h.store.sessionEndRecordedInLog()) + } + + /// session.json removed outside the lock cannot empty the record, whole + /// or cut short: it is logged and stays, ending nothing, and the + /// agent's next run empties it in place. Content that is no record + /// stays while session.json cannot be read. + func testARecordLeftByACleanupWithoutTheLockIsEmptiedByTheNextRun() async throws { + let m = h.makeManager() + await m.reconcile() + let lockInode = try inode(lockFile) + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let ended = record(of: try Data(contentsOf: h.home.paths.sessionFile)) + try writeInPlace(ended, to: lockFile) + + try h.store.deleteSession() + + XCTAssertEqual(lockBytes(), ended) + XCTAssertTrue(logText().contains("could not empty \(lockFile.path) of the record of a session's end"), logText()) + try await runAgent(expecting: 0) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let cut = ended.prefix(ended.count / 2) + try writeInPlace(cut, to: lockFile) + XCTAssertNotNil(h.store.sessionEndRecordedInLock()) + try h.store.deleteSession() + XCTAssertEqual(lockBytes(), cut) + XCTAssertNil(h.store.sessionEndRecordedInLock(), "nothing to end without session.json") + try agent.clearCalls() + try await runAgent(expecting: 0) + XCTAssertTrue(logText().contains("emptying \(lockFile.path): it holds bytes other than one whole end record, which ends no session"), logText()) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + + // While session.json cannot be read, nothing shows that such content + // ends nothing, so the run keeps it. That run removes session.json, + // whose end time is unknown, and the next run empties the content. + h.clock.advance(60) + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let unread = record(of: try Data(contentsOf: h.home.paths.sessionFile)).prefix(40) + try writeInPlace(unread, to: lockFile) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: h.home.paths.sessionFile.path) + var mark = logText().count + try agent.clearCalls() + try await runAgent(expecting: 0) + XCTAssertTrue(logText(since: mark).contains("session.json cannot be read"), logText(since: mark)) + XCTAssertFalse(logText(since: mark).contains("emptying \(lockFile.path)"), logText(since: mark)) + XCTAssertEqual(lockBytes(), unread) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.sessionFile.path), logText(since: mark)) + mark = logText().count + try await runAgent(expecting: 0) + XCTAssertTrue(logText(since: mark).contains("emptying \(lockFile.path): it holds bytes other than one whole end record, which ends no session"), logText(since: mark)) + XCTAssertEqual(lockBytes(), Data()) + XCTAssertEqual(try inode(lockFile), lockInode) + } +} diff --git a/Tests/InsomniaTests/LogEndRecordTests.swift b/Tests/InsomniaTests/LogEndRecordTests.swift new file mode 100644 index 00000000..272dfb13 --- /dev/null +++ b/Tests/InsomniaTests/LogEndRecordTests.swift @@ -0,0 +1,1010 @@ +import Darwin +import Foundation +import XCTest +@testable import Insomnia + +/// The place after the recovery lock file: one line appended to +/// insomnia.log (`LogEndRecord`), for an end that cannot remove +/// session.json while ended-session.json, the journal, both folders of +/// records aside and the lock file all refuse the record. The line holds +/// session.json's exact bytes and their count, and counts only as a whole +/// line equal to the one built from session.json as it is now, in +/// insomnia.log or insomnia.log.1. The app rotates the log only under the +/// recovery lock and copies a record still in force into the file it +/// renames. The agent is the real backstop.sh with its tools patched to +/// fakes (PatchedBackstop). The app's lock file refuses its record through +/// `Store.lockRecordWriteLimitForTesting`, as the agent's does through +/// `refuseLockRecord`. +@MainActor +final class LogEndRecordTests: XCTestCase { + var h: Harness! + var agent: PatchedBackstop! + let acls = OwnedACLs() + /// Settled before the home goes, also when the test stopped early. + var logWriters: PatchedBackstop.LogWriters? + + override func setUp() async throws { + h = Harness() + try h.home.paths.createDirectories() + agent = try PatchedBackstop(home: h.home.root, dir: h.home.root.appendingPathComponent("agent", isDirectory: true)) + } + + /// A log writer that has not finished may still write to the log, so + /// its home stays. + override func tearDown() async throws { + var keepHome = false + if let logWriters { + do { + try await logWriters.settle() + } catch let error as PatchedBackstop.LogWriters.Unsettled { + XCTFail("\(error); keeping \(h.home.root.path)") + keepHome = true + } catch { + XCTFail("\(error)") + } + } + Store.lockRecordWriteLimitForTesting = nil + acls.removeGiven() + unpinAll() + for url in [logFile, rotatedLog, h.home.paths.sessionFile] { + try? setImmutable(url, false) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + if keepHome { h.home.keep() } else { h.home.destroy() } + } + + private var logFile: URL { h.home.paths.logFile } + private var rotatedLog: URL { OwnerOnly.rotated(h.home.paths.logFile) } + private var lockFile: URL { h.home.paths.recoveryLock } + private let unrelatedRecord = Data("an end record of some other session.json".utf8) + + /// The record line of `data`, without its newline. + private func line(of data: Data) -> String { + "\(LogEndRecord.tag) \(data.count) \(data.base64EncodedString())" + } + + private func sessionBytes() throws -> Data { try Data(contentsOf: h.home.paths.sessionFile) } + + private func text(_ url: URL) -> String { (try? String(contentsOf: url, encoding: .utf8)) ?? "" } + + private func logText() -> String { text(logFile) } + + /// How many lines of `url` equal `line`. + private func count(_ line: String, in url: URL) -> Int { + text(url).components(separatedBy: "\n").filter { $0 == line }.count + } + + private func size(_ url: URL) throws -> UInt64 { + try XCTUnwrap((try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.uint64Value) + } + + /// One line longer than the log's cap, so the next line written under + /// the recovery lock rotates it. + nonisolated static func appendFiller(to url: URL) { + guard let handle = try? FileHandle(forWritingTo: url) else { return } + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data(repeating: UInt8(ascii: "a"), count: Int(OwnerOnly.maxLogBytes)) + Data("\n".utf8)) + } + + private func append(_ text: String, to url: URL) throws { + if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(text.utf8)) + } + + private func sleepHeldAgain(since count: Int) -> Bool { + h.guardFake.calls.dropFirst(count).contains("disablesleep 1") + } + + private func runAgent(expecting status: Int32, file: StaticString = #filePath, line: UInt = #line) async throws { + let exit = try await agent.run() + XCTAssertEqual(exit, status, logText(), file: file, line: line) + } + + private var pinnable: [URL] { [h.home.paths.sessionFile, h.home.paths.endedSessionFile, h.home.paths.stateFile] } + + /// A running session whose files take no record: session.json, an + /// unrelated ended-session.json and state.json are immutable. + private func startThenPinAll() async throws -> SessionManager { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertTrue(FileManager.default.fileExists(atPath: logFile.path), "the start wrote the log") + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + for file in pinnable { try setImmutable(file, true) } + return m + } + + private func unpinAll() { + for file in pinnable { try? setImmutable(file, false) } + } + + /// The app can record the end nowhere but the log: neither folder + /// takes a new file and the lock file takes no record. + private func refuseAllButTheLogForTheApp() throws { + try acls.denyNewFiles(in: h.home.paths.appSupport) + try acls.denyNewFiles(in: h.home.paths.logs) + Store.lockRecordWriteLimitForTesting = 0 + } + + private func repairAll() throws { + try acls.removeAll(h.home.paths.appSupport) + try acls.removeAll(h.home.paths.logs) + Store.lockRecordWriteLimitForTesting = nil + unpinAll() + } + + private func endedInLogLine(_ name: String = "insomnia.log") -> String { + "reconcile: session.json holds a session already ended (recorded in \(name)); restoring, not resuming" + } + + // MARK: Reading + + /// The app's reader (Store.sessionEndRecordedInLog) and the agent's + /// (end_recorded_in_log) on the same logs, one home per case: whether + /// they count the session in session.json as ended. Only a whole line + /// equal to the record of exactly these bytes counts, in a regular file + /// (not a symlink) that can be read. The app is alive and the machine + /// within every floor, so the agent ends only a session it finds + /// recorded as ended. The homes are built one at a time and the agents + /// run up to 8 at once (SeparateRun). + func testTheAppAndTheAgentReadEveryShapeOfLineAlike() async throws { + enum Shape { case file, symlink, directory, writeOnly } + /// The record with its byte count one higher. + func recount(_ l: String) -> String { + var parts = l.components(separatedBy: " ") + parts[1] = String(Int(parts[1])! + 1) + return parts.joined(separator: " ") + } + let tag = LogEndRecord.tag + let cases: [(name: String, ends: Bool, shape: Shape, log: (String, String) -> String, rotated: ((String, String) -> String)?)] = [ + ("no record", false, .file, { _, _ in "2027-01-15T08:00:00Z [info] insomnia: hello\n" }, nil), + ("this session", true, .file, { l, _ in "a line\n\(l)\nanother line\n" }, nil), + ("this session, last line without its newline", true, .file, { l, _ in "a line\n\(l)" }, nil), + ("this session twice", true, .file, { l, _ in "\(l)\n\(l)\n" }, nil), + ("this session after a line too long to be a record", true, .file, { l, _ in String(repeating: "a", count: LogEndRecord.maxLineBytes + 10) + "\n\(l)\n" }, nil), + ("this session in insomnia.log.1", true, .file, { _, _ in "a line\n" }, { l, _ in "\(l)\n" }), + ("a NUL byte on another line", true, .file, { l, _ in "a\u{0}b\n\(l)\n" }, nil), + ("another session", false, .file, { _, o in "\(o)\n" }, { _, o in "\(o)\n" }), + ("another byte count", false, .file, { l, _ in recount(l) + "\n" }, nil), + ("these bytes under the lock file's tag", false, .file, { l, _ in "ended-session-v1 \(l.components(separatedBy: " ")[2])\n" }, nil), + ("cut short", false, .file, { l, _ in String(l.dropLast(4)) + "\n" }, nil), + ("cut short at the end of the file", false, .file, { l, _ in String(l.dropLast(1)) }, nil), + ("text before it on its line", false, .file, { l, _ in "2027-01-15T08:00:00Z [info] insomnia: \(l)\n" }, nil), + ("broken by another line", false, .file, { l, _ in String(l.prefix(40)) + "\n2027-01-15T08:00:00Z [info] insomnia: x\n" + String(l.dropFirst(40)) + "\n" }, nil), + ("carriage return", false, .file, { l, _ in "\(l)\r\n" }, nil), + ("trailing space", false, .file, { l, _ in "\(l) \n" }, nil), + ("two spaces after the tag", false, .file, { l, _ in l.replacingOccurrences(of: "\(tag) ", with: "\(tag) ") + "\n" }, nil), + ("insomnia.log a symlink to a file holding it", false, .symlink, { l, _ in "\(l)\n" }, nil), + ("insomnia.log a directory, insomnia.log.1 holding it", true, .directory, { _, _ in "" }, { l, _ in "\(l)\n" }), + ("insomnia.log unreadable, insomnia.log.1 holding it", true, .writeOnly, { _, _ in "a line\n" }, { l, _ in "\(l)\n" }), + ("insomnia.log unreadable and holding it", false, .writeOnly, { l, _ in "\(l)\n" }, nil), + ] + let config = try Store.makeEncoder().encode(Config()) + let other = line(of: Data("an earlier session.json".utf8)) + var runs: [SeparateRun] = [] + for (i, c) in cases.enumerated() { + let run = try SeparateRun(in: h, name: "\(i)", config: config, battery: 25) + runs.append(run) + let paths = run.paths + let l = line(of: try Data(contentsOf: paths.sessionFile)) + let log = Data(c.log(l, other).utf8) + switch c.shape { + case .file, .writeOnly: + try log.write(to: paths.logFile) + case .symlink: + let target = paths.logs.appendingPathComponent("elsewhere.log") + try log.write(to: target) + try FileManager.default.createSymbolicLink(at: paths.logFile, withDestinationURL: target) + case .directory: + try FileManager.default.createDirectory(at: paths.logFile, withIntermediateDirectories: false) + } + if let rotated = c.rotated { try Data(rotated(l, other).utf8).write(to: OwnerOnly.rotated(paths.logFile)) } + if c.shape == .writeOnly { try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: paths.logFile.path) } + XCTAssertEqual(Store(paths: paths).sessionEndRecordedInLog() != nil, c.ends, "the app: \(c.name)") + } + + let results = try await SeparateRun.runAll(runs) + + for (i, c) in cases.enumerated() { + let paths = runs[i].paths + if c.shape == .writeOnly { try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: paths.logFile.path) } + let log = try c.shape == .file ? runs[i].log : "" + XCTAssertEqual(results[i].status, 0, "\(c.name): \(log)") + XCTAssertEqual(results[i].ended, c.ends, "the agent: \(c.name): \(log)") + XCTAssertEqual(runs[i].agent.calls.contains("pmset -g batt"), !c.ends, "\(c.name): checked only when not ended") + if c.shape == .file { + XCTAssertEqual(log.contains("already ended (recorded in \(paths.logFile.path)"), c.ends, "\(c.name): \(log)") + } + } + } + + // MARK: Writing + + /// The Store appends the record only through the recovery lock this + /// process holds on this home's lock file, never through a symlink, + /// never into a log it would have to create, and counts it only once it + /// reads back whole. A record already there is used again. A log that + /// takes no write records nothing; one that cannot be read back gets + /// the line, which counts once the log can be read. + func testTheStoreAppendsTheRecordOnlyUnderTheHeldLockAndCountsItOnlyOnceItReadsBack() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let bytes = try sessionBytes() + let record = line(of: bytes) + XCTAssertEqual(LogEndRecord.line(for: bytes), Data(record.utf8)) + try Data("an earlier line\n".utf8).write(to: logFile) + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + + XCTAssertFalse(h.store.recordSessionEndInLog(lock: nil)) + let elsewhere = try XCTUnwrap(try RecoveryLock(url: h.home.root.appendingPathComponent("other.lock")).tryAcquire()) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: elsewhere), "a lock on another file does not count") + elsewhere.release() + XCTAssertFalse(logText().contains(LogEndRecord.tag), "nothing is written without the lock") + + XCTAssertTrue(h.store.recordSessionEndInLog(lock: held)) + XCTAssertTrue(logText().hasPrefix("an earlier line\n"), logText()) + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + XCTAssertTrue(h.store.recordSessionEndInLog(lock: held), "a record already there is used again") + XCTAssertEqual(count(record, in: logFile), 1) + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(1200))) + let newer = line(of: try sessionBytes()) + XCTAssertNil(h.store.sessionEndRecordedInLog(), "a record of other bytes ends nothing") + + try setImmutable(logFile, true) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held), "a log that takes no write") + try setImmutable(logFile, false) + XCTAssertEqual(count(newer, in: logFile), 0) + + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: logFile.path) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held), "a log that cannot be read back") + XCTAssertNil(h.store.sessionEndRecordedInLog()) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: logFile.path) + XCTAssertEqual(count(newer, in: logFile), 1, "written, though not read back") + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log", "whole, so it counts once it can be read") + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(1800))) + try FileManager.default.removeItem(at: logFile) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held)) + XCTAssertFalse(FileManager.default.fileExists(atPath: logFile.path), "no log is created for it") + + let target = h.home.root.appendingPathComponent("elsewhere.log") + try Data().write(to: target) + try FileManager.default.createSymbolicLink(at: logFile, withDestinationURL: target) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held)) + XCTAssertEqual(try Data(contentsOf: target), Data(), "nothing written through the symlink") + try Data((line(of: try sessionBytes()) + "\n").utf8).write(to: target) + XCTAssertNil(h.store.sessionEndRecordedInLog(), "nothing read through the symlink") + try FileManager.default.removeItem(at: logFile) + try Data().write(to: logFile) + + let big = Data(repeating: 0x20, count: LogEndRecord.maxSessionBytes + 1) + XCTAssertNil(LogEndRecord.line(for: big)) + XCTAssertNil(LogEndRecord.line(for: Data())) + XCTAssertNotNil(LogEndRecord.line(for: Data(repeating: 0x20, count: LogEndRecord.maxSessionBytes))) + XCTAssertEqual(LogEndRecord.line(for: Data(repeating: 0x20, count: LogEndRecord.maxSessionBytes))?.count, LogEndRecord.maxLineBytes) + try big.write(to: h.home.paths.sessionFile) + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held), "a session.json past the largest a record copies") + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(2400))) + held.release() + XCTAssertFalse(h.store.recordSessionEndInLog(lock: held), "a released handle writes nothing") + XCTAssertEqual(try Data(contentsOf: logFile), Data()) + } + + // MARK: Rotation + + /// Without the recovery lock the log is not rotated: the line goes to + /// the file as it is. Under the lock it is, and each rotation copies a + /// record of session.json's bytes from the old insomnia.log.1 into the + /// file it renames, so three rotations in a row keep it. Once + /// session.json holds other bytes, or is gone, the record ends nothing + /// and a rotation drops it. + func testRotationsUnderTheLockCarryARecordForwardWhileItsSessionJSONIsThere() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let record = line(of: try sessionBytes()) + try Data("\(record)\n".utf8).write(to: logFile) + Self.appendFiller(to: logFile) + + Log.append(level: "info", "no lock held", paths: h.home.paths) + XCTAssertFalse(FileManager.default.fileExists(atPath: rotatedLog.path), "not rotated without the lock") + XCTAssertTrue(logText().hasSuffix("insomnia: no lock held\n")) + XCTAssertGreaterThan(try size(logFile), OwnerOnly.maxLogBytes) + + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + func rotate(_ text: String) { + RecoveryLock.$held.withValue(held) { Log.append(level: "info", text, paths: h.home.paths) } + } + rotate("first rotation") + XCTAssertEqual(count(record, in: rotatedLog), 1) + XCTAssertEqual(count(record, in: logFile), 0) + XCTAssertTrue(logText().hasSuffix("insomnia: first rotation\n")) + XCTAssertLessThan(try size(logFile), 200) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log.1") + + for n in ["second", "third"] { + Self.appendFiller(to: logFile) + rotate("\(n) rotation") + XCTAssertEqual(count(record, in: rotatedLog), 1, "copied forward before the old .1 went: \(n)") + XCTAssertEqual(count(record, in: logFile), 0, n) + XCTAssertTrue(logText().hasSuffix("insomnia: \(n) rotation\n"), n) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log.1", n) + } + + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(1200))) + Self.appendFiller(to: logFile) + rotate("other bytes") + XCTAssertEqual(count(record, in: rotatedLog) + count(record, in: logFile), 0, "a record of other bytes is dropped") + + let newer = line(of: try sessionBytes()) + try append("\(newer)\n", to: logFile) + Self.appendFiller(to: logFile) + rotate("into .1") + XCTAssertEqual(count(newer, in: rotatedLog), 1) + try FileManager.default.removeItem(at: h.home.paths.sessionFile) + Self.appendFiller(to: logFile) + rotate("session.json gone") + XCTAssertEqual(count(newer, in: rotatedLog) + count(newer, in: logFile), 0, "dropped once session.json is gone") + } + + /// A rotation that cannot tell whether the old insomnia.log.1 holds a + /// record still in force waits: .1 cannot be read, or session.json + /// cannot be read and .1 holds any record. The line goes to the file + /// as it is, and the next line under the lock rotates once both can be + /// read, copying the record forward. + func testARotationWaitsWhileItCannotTellWhetherDotOneHoldsARecordInForce() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let record = line(of: try sessionBytes()) + try Data("\(record)\n".utf8).write(to: rotatedLog) + try Data("a line\n".utf8).write(to: logFile) + Self.appendFiller(to: logFile) + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + func rotate(_ text: String) { + RecoveryLock.$held.withValue(held) { Log.append(level: "info", text, paths: h.home.paths) } + } + + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: rotatedLog.path) + rotate(".1 unreadable") + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: rotatedLog.path) + XCTAssertTrue(logText().hasSuffix("insomnia: .1 unreadable\n"), "the line goes to the file as it is") + XCTAssertGreaterThan(try size(logFile), OwnerOnly.maxLogBytes) + XCTAssertEqual(text(rotatedLog), "\(record)\n", ".1 is kept") + + rotate("readable again") + XCTAssertEqual(count(record, in: rotatedLog), 1, "copied forward") + XCTAssertTrue(logText().hasSuffix("insomnia: readable again\n")) + XCTAssertLessThan(try size(logFile), 200) + + Self.appendFiller(to: logFile) + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: h.home.paths.sessionFile.path) + rotate("session.json unreadable") + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: h.home.paths.sessionFile.path) + XCTAssertGreaterThan(try size(logFile), OwnerOnly.maxLogBytes, "kept while .1 holds a record") + XCTAssertEqual(count(record, in: rotatedLog), 1) + + rotate("session.json readable again") + XCTAssertEqual(count(record, in: rotatedLog), 1) + XCTAssertLessThan(try size(logFile), 200) + } + + /// The agent ends a session whose end it can record only in the log + /// while this process writes lines beside it and, now and then, takes + /// the recovery lock and rotates a log past the cap. The record reads + /// back whole, and two more rotations after the run keep it while + /// session.json is there. + func testTheAgentsRecordReadsBackWholeBesideOtherLinesAndLockedRotations() async throws { + _ = try await startThenPinAll() + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + let record = line(of: try sessionBytes()) + let beside = BesideTheAgent(paths: h.home.paths) + beside.start() + + let exit = try await agent.run() + let (lines, rotations) = beside.stop() + + XCTAssertEqual(exit, 1, logText()) + XCTAssertGreaterThan(lines, 0) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(count(record, in: logFile) + count(record, in: rotatedLog), 1, "rotations \(rotations)") + XCTAssertNotNil(h.store.sessionEndRecordedInLog()) + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + for n in 1...2 { + Self.appendFiller(to: logFile) + RecoveryLock.$held.withValue(held) { Log.append(level: "info", "rotation \(n) after the run", paths: h.home.paths) } + } + held.release() + XCTAssertEqual(count(record, in: rotatedLog), 1) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log.1") + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try agent.clearCalls() + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(rotatedLog.path))"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt")) + } + + // MARK: The app's end + + /// The app ends a session whose end it can record only in the log + /// (every file pinned, neither folder takes a new file, the lock file + /// takes no record): the record goes in before anything is undone. + /// After every file, both folders and the lock file are repaired, a + /// relaunch with SleepDisabled still 1 ends it instead of holding sleep + /// again, and removes session.json. + func testAnAppEndRecordedOnlyInTheLogIsNotResumedAfterAFullRepair() async throws { + let m = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try refuseAllButTheLogForTheApp() + + let outcome = await m.end(reason: .user) + + XCTAssertEqual(outcome, .sessionRetained) + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(try Data(contentsOf: lockFile), Data(), "no record in the lock file") + XCTAssertEqual(h.store.sessionEndRecordsAside(), []) + XCTAssertTrue(logText().contains("could not remove session.json: "), logText()) + XCTAssertTrue(logText().contains("; its end is recorded in the log file insomnia.log"), logText()) + XCTAssertTrue(h.notifier.posts.last?.body.contains("its end is recorded, so a relaunch will not resume it") == true, "\(h.notifier.posts)") + + try repairAll() + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertTrue(logText().contains(endedInLogLine()), logText()) + XCTAssertNil(try h.store.loadSession()) + + let again = h.makeManager() + await again.reconcile() + XCTAssertFalse(again.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + } + + /// Settings' change of the end floor that config.json refuses, with a + /// journal record that cannot be put back either, ends the session on + /// disk under the lock it holds (`updateConfig`). Here the end can be + /// recorded only in the log. A relaunch from the files as the refused + /// change left them does not hold sleep again; after the repair the + /// end in process, pending until then, finishes on its retry and + /// nothing holds sleep again either. + func testARefusedSettingsChangeThatCannotBePutBackRecordsTheEndInTheLog() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + let holds = h.guardFake.calls.filter { $0 == "disablesleep 1" }.count + let record = line(of: try sessionBytes()) + try unrelatedRecord.write(to: h.home.paths.endedSessionFile) + try setImmutable(h.home.paths.sessionFile, true) + try setImmutable(h.home.paths.endedSessionFile, true) + try setImmutable(h.home.paths.configFile, true) + defer { try? setImmutable(h.home.paths.configFile, false) } + Store.lockRecordWriteLimitForTesting = 0 + // The journal takes the new cutoffs first; then it, both folders + // and the lock file refuse everything. + let paths = h.home.paths + let acls = self.acls + m.beforeRecordedCutoffsPutBack = { + do { + try setImmutable(paths.stateFile, true) + try acls.denyNewFiles(in: paths.appSupport) + try acls.denyNewFiles(in: paths.logs) + } catch { XCTFail("not refused: \(error)") } + } + + XCTAssertFalse(m.updateConfig { $0.setEndFloor(m.config.agentCutoffs.endFloor == 30 ? 10 : 30) }) + + // Nothing has awaited yet: the disk is what the refused change left. + XCTAssertEqual(count(record, in: logFile), 1) + let error = try XCTUnwrap(m.configSaveError) + XCTAssertTrue(error.contains("so Insomnia ended the session: session.json could not be removed ("), error) + XCTAssertTrue(error.contains("its end is recorded, so a relaunch will not resume it."), error) + let copy = Harness() + setenv(Paths.environmentKey, h.home.root.path, 1) + defer { try? FileManager.default.removeItem(at: copy.home.root) } + try copy.home.paths.createDirectories() + for file in [h.home.paths.sessionFile, h.home.paths.stateFile, h.home.paths.configFile, h.home.paths.endedSessionFile, logFile] { + try Data(contentsOf: file).write(to: file == logFile ? copy.home.paths.logFile : copy.home.paths.appSupport.appendingPathComponent(file.lastPathComponent)) + } + copy.guardFake.sleepDisabled = true + let relaunch = copy.makeManager() + await relaunch.reconcile() + XCTAssertFalse(relaunch.isActive) + XCTAssertFalse(copy.guardFake.calls.contains("disablesleep 1"), "\(copy.guardFake.calls)") + XCTAssertTrue(copy.guardFake.calls.contains("disablesleep 0"), "\(copy.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLogLine()), logText()) + + try repairAll() + try setImmutable(paths.stateFile, false) + try setImmutable(h.home.paths.configFile, false) + m.beforeRecordedCutoffsPutBack = nil + // The end in process ran while the files refused it, so it is + // pending; this is its retry, as the retry timer runs it. + XCTAssertEqual(m.pendingEnd, .cutoffsNotRecorded) + _ = await m.end(reason: .cutoffsNotRecorded) + XCTAssertFalse(m.isActive) + XCTAssertNil(m.pendingEnd, logText()) + let again = h.makeManager() + await again.reconcile() + XCTAssertFalse(again.isActive) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, holds, "nothing held sleep again") + } + + // MARK: The agent's end + + /// The agent ends a session whose end it can record only in the log + /// (every file pinned, MKTEMP fails, the lock file takes no record), + /// and the restore fails, so SleepDisabled stays 1. Its next run reads + /// the record and does not check the session again. After every file + /// is repaired, the app launches first with SleepDisabled still 1: it + /// ends the session instead of holding sleep again and removes + /// session.json. + func testAnAgentEndRecordedOnlyInTheLogIsNotRevivedAfterAFailedRestoreAndAFullRepair() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.failSudo() + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(try Data(contentsOf: lockFile), Data()) + XCTAssertTrue(logText().contains("its end is recorded in the log file \(logFile.path) instead"), logText()) + XCTAssertTrue(h.guardFake.sleepDisabled, "the failed restore left it at 1") + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try agent.clearCalls() + try await runAgent(expecting: 1) + XCTAssertTrue(logText().contains("already ended (recorded in \(logFile.path))"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt")) + XCTAssertEqual(count(record, in: logFile), 1, "used again, not appended again") + + unpinAll() + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLogLine()), logText()) + XCTAssertNil(try h.store.loadSession()) + try agent.clearCalls() + try await runAgent(expecting: 0) + XCTAssertFalse(agent.calls.contains(agent.restoreCall), "nothing left to restore: \(agent.calls)") + } + + /// The app still running when the agent ends its session that way: the + /// tick, which read the logs before the run and found nothing, reads + /// them again once they changed and ends the session too. + func testTheTickEndsASessionTheAgentEndedInTheLog() async throws { + let m = try await startThenPinAll() + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try await runAgent(expecting: 1) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + + let before = h.guardFake.calls.count + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains("is recorded as ended in insomnia.log: the recovery agent ended it"), logText()) + } + + /// The agent appends the record but cannot read it back (GREP finds no + /// record line): it does not count it, keeps the sleep entry and says + /// the end is recorded nowhere. The line is whole, so the app reads it + /// and does not resume the session after the repair. + func testARecordTheAgentCannotReadBackDoesNotCountForTheAgent() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try agent.failLogReadBack() + + try await runAgent(expecting: 1) + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + XCTAssertEqual(count(record, in: logFile), 1, "written, though not read back") + XCTAssertTrue(logText().contains("the recovery lock file \(lockFile.path), or the log file \(logFile.path). Sleep is restored anyway"), logText()) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + + unpinAll() + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + XCTAssertFalse(next.isActive) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLogLine()), logText()) + } + + // MARK: Lines cut short + + /// A record whose newline alone is missing counts, and the app's + /// ordinary lines (`Log.append`) put a newline first, so it still + /// counts after them; a log that ends in a newline gets no blank line. + /// A record of other bytes at the end of the file ends nothing, before + /// or after. The record writer separates a line cut short the same + /// way, whether another write left it or one of the app's lines + /// stopped partway (the file size limit), and its record reads back on + /// the first attempt. Part of a record counts as nothing and stays a + /// line of its own; the retry then counts. In a log this user may only + /// write to, the last byte cannot be read, so a newline always goes + /// first. + func testEveryAppWriterStartsOnALineOfItsOwnAfterALineCutShort() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let record = line(of: try sessionBytes()) + let other = line(of: Data("an earlier session.json".utf8)) + func lines() -> [String] { logText().components(separatedBy: "\n") } + + try Data("a line\n\(other)".utf8).write(to: logFile) + XCTAssertNil(h.store.sessionEndRecordedInLog()) + Log.append(level: "info", "after another record", paths: h.home.paths) + XCTAssertEqual(Array(lines().prefix(2)), ["a line", other]) + XCTAssertNil(h.store.sessionEndRecordedInLog()) + + try Data("a line\n\(record)".utf8).write(to: logFile) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log", "at the end of the file, without its newline") + Log.append(level: "info", "after the record", paths: h.home.paths) + Log.append(level: "info", "and again", paths: h.home.paths) + XCTAssertEqual(lines().count, 5, logText()) + XCTAssertEqual(Array(lines().prefix(2)), ["a line", record]) + XCTAssertTrue(lines()[2].hasSuffix("insomnia: after the record"), logText()) + XCTAssertTrue(lines()[3].hasSuffix("insomnia: and again"), logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + try Data("a line\ncut sh".utf8).write(to: logFile) + XCTAssertTrue(h.store.recordSessionEndInLog(lock: held), "the first attempt") + XCTAssertEqual(logText(), "a line\ncut sh\n\(record)\n") + + // The size limit stops these writes in an empty log, where the end + // of the file and the start of a new descriptor are the same offset: + // an append here is measured from the descriptor's offset. + try Data().write(to: logFile) + try withFileSizeLimit(6) { Log.append(level: "info", "stopped partway", paths: h.home.paths) } + XCTAssertEqual(try size(logFile), 6, "six bytes of the line") + XCTAssertTrue(h.store.recordSessionEndInLog(lock: held)) + XCTAssertEqual(lines().count, 3, logText()) + XCTAssertEqual(lines()[1], record) + + try Data().write(to: logFile) + XCTAssertFalse(try withFileSizeLimit(20) { h.store.recordSessionEndInLog(lock: held) }) + XCTAssertEqual(logText(), String(record.prefix(20))) + XCTAssertNil(h.store.sessionEndRecordedInLog(), "part of a record counts as nothing") + Log.append(level: "info", "after the part", paths: h.home.paths) + XCTAssertEqual(lines()[0], String(record.prefix(20))) + XCTAssertNil(h.store.sessionEndRecordedInLog()) + XCTAssertTrue(h.store.recordSessionEndInLog(lock: held), "the retry") + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(lines().count, 4, logText()) + + try Data("a line\n\(record)".utf8).write(to: logFile) + try FileManager.default.setAttributes([.posixPermissions: 0o200], ofItemAtPath: logFile.path) + Log.append(level: "info", "write-only", paths: h.home.paths) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: logFile.path) + XCTAssertEqual(Array(lines().prefix(2)), ["a line", record]) + XCTAssertTrue(lines()[2].hasSuffix("insomnia: write-only"), logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + } + + /// Two rotations under the lock with lines cut short. A record whose + /// newline alone is missing goes to .1 as it is and still counts there. + /// The next rotation copies it forward into a log that ends in a line + /// cut short, after a newline, so the copy reads back and .1 then holds + /// it whole beside that line. + func testTwoRotationsKeepARecordWithoutItsNewlineAndALineCutShortApart() throws { + try h.store.saveSession(Session(startedAt: h.clock.now, endsAt: h.clock.now.addingTimeInterval(600))) + let record = line(of: try sessionBytes()) + try Data("a line\n".utf8).write(to: logFile) + Self.appendFiller(to: logFile) + try append(record, to: logFile) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + let held = try XCTUnwrap(try RecoveryLock(url: lockFile).tryAcquire()) + defer { held.release() } + func rotate(_ text: String) { + RecoveryLock.$held.withValue(held) { Log.append(level: "info", text, paths: h.home.paths) } + } + + rotate("first rotation") + XCTAssertTrue(text(rotatedLog).hasSuffix("a\n\(record)"), "renamed as it was") + XCTAssertEqual(count(record, in: logFile), 0) + XCTAssertTrue(logText().hasSuffix("insomnia: first rotation\n"), logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log.1") + + Self.appendFiller(to: logFile) + try append("cut sh", to: logFile) + rotate("second rotation") + XCTAssertTrue(text(rotatedLog).hasSuffix("a\ncut sh\n\(record)\n"), String(text(rotatedLog).suffix(120))) + XCTAssertEqual(count(record, in: rotatedLog), 1) + XCTAssertEqual(count(record, in: logFile), 0) + XCTAssertTrue(logText().hasSuffix("insomnia: second rotation\n"), logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log.1") + } + + /// The tick adopts an end recorded only in the log as a record whose + /// newline alone is missing (a write that stopped at its last byte) and + /// logs that before it ends the session; every other file still + /// refuses the end. The lines it logs start on lines of their own, so + /// the record still counts: a relaunch after a full repair, with + /// SleepDisabled still 1, ends the session instead of holding sleep + /// again. + func testTheTickAdoptsARecordWithoutItsNewlineAndKeepsIt() async throws { + let m = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try refuseAllButTheLogForTheApp() + try append(record, to: logFile) + + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive, logText()) + XCTAssertTrue(logText().contains("is recorded as ended in insomnia.log: the recovery agent ended it"), logText()) + XCTAssertTrue(logText().contains("\(record)\n"), logText()) + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + XCTAssertNotNil(try h.store.loadSession(), "session.json could not be removed") + + try repairAll() + h.guardFake.sleepDisabled = true + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + let before = h.guardFake.calls.count + let next = h.makeManager() + await next.reconcile() + + XCTAssertFalse(next.isActive, logText()) + XCTAssertFalse(sleepHeldAgain(since: before), "\(h.guardFake.calls)") + XCTAssertTrue(logText().contains(endedInLogLine()), logText()) + XCTAssertNil(try h.store.loadSession()) + } + + /// The agent finds the end recorded in the log as a record whose + /// newline alone is missing, ends the session again without the checks + /// and logs that. Each line it writes starts on a line of its own, so + /// the record still counts for its next run and for the app. + func testTheAgentsLinesLeaveARecordWithoutItsNewlineWhole() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try append(record, to: logFile) + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + + try await runAgent(expecting: 1) + + XCTAssertTrue(logText().contains("already ended (recorded in \(logFile.path))"), logText()) + XCTAssertTrue(logText().contains("\(record)\n"), logText()) + XCTAssertFalse(agent.calls.contains("pmset -g batt")) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + + try agent.clearCalls() + try await runAgent(expecting: 1) + XCTAssertFalse(agent.calls.contains("pmset -g batt"), "still ended for the next run") + XCTAssertEqual(count(record, in: logFile), 1, "used again, not appended again") + } + + /// The agent writes its record into a log that ends in a line cut + /// short: another write leaves `cut sh` at the end before each of its + /// checks for a record, the one just before the write included. The + /// record starts on a line of its own and reads back on the first + /// attempt, the line cut short stays as it was, and the app counts the + /// record too. + func testTheAgentsRecordAfterALineCutShortReadsBackOnTheFirstAttempt() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try agent.cutTheLogShortAtEveryRecordCheck(log: logFile) + + try await runAgent(expecting: 1) + + XCTAssertTrue(logText().contains("its end is recorded in the log file \(logFile.path) instead"), logText()) + XCTAssertTrue(logText().contains("cut sh\n\(record)\n"), logText()) + XCTAssertEqual(count(record, in: logFile), 1) + let cut = logText().components(separatedBy: "\n").filter { $0.contains("cut sh") } + XCTAssertFalse(cut.isEmpty) + XCTAssertEqual(Set(cut), ["cut sh"], logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + } + + /// The agent's writes to the log wait for another writer that holds + /// the log's lock. Before each of the agent's checks for a record made + /// while no one holds that lock, another writer takes it, writes + /// `held sh`, waits a second, writes `ort` and lets go. The record, + /// and the line the agent logs right after its last check, each wait + /// for the whole of that line and start on lines of their own: no + /// write lands inside it. The record reads back on the first attempt + /// and the app counts it. + func testTheAgentsWritesWaitForAWriterThatHoldsTheLog() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + let writers = try agent.holdTheLogAtEveryRecordCheck(log: logFile) + logWriters = writers + + try await runAgent(expecting: 1) + let statuses = try await writers.settle() + XCTAssertGreaterThanOrEqual(statuses.count, 2) + XCTAssertEqual(Set(statuses), [0], "each writer took the lock and let go") + + let recorded = "its end is recorded in the log file \(logFile.path) instead" + XCTAssertTrue(logText().contains("held short\n\(record)\nheld short\n"), logText()) + let after = try XCTUnwrap(logText().components(separatedBy: "held short\n\(record)\nheld short\n").last) + XCTAssertTrue(try XCTUnwrap(after.components(separatedBy: "\n").first).hasSuffix(recorded + ", so Insomnia restores the session instead of resuming it. Every run retries the removal"), after) + XCTAssertEqual(count(record, in: logFile), 1) + let held = logText().components(separatedBy: "\n").filter { $0.contains("held sh") || $0.hasPrefix("ort") } + XCTAssertGreaterThanOrEqual(held.count, 2) + XCTAssertEqual(Set(held), ["held short"], logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + } + + /// While another writer holds the log's lock for the whole run, every + /// write of the agent's to the log waits at most + /// LOG_LOCK_TIMEOUT_SECONDS (1 here) and then writes nothing there: + /// the log keeps exactly what that writer left, a line cut short. The + /// record goes in nowhere, so the agent keeps the sleep entry, and each + /// line it would have logged goes to standard error instead, saying + /// why. Once that writer lets go, the next run's first line starts + /// after a newline, and it records the end in the log. + func testTheAgentWritesNothingToALogItCannotLockAndSaysSo() async throws { + _ = try await startThenPinAll() + let record = line(of: try sessionBytes()) + try agent.refuseRecordsAside() + try agent.refuseLockRecord() + try agent.setLogLockTimeout(1) + let holder = try LogLockHolder(logFile) + defer { holder.release() } + try holder.write("cut sh") + let before = logText() + + try await runAgent(expecting: 1) + + XCTAssertEqual(logText(), before) + XCTAssertTrue(agent.calls.contains(agent.restoreCall), agent.calls.joined(separator: "\n")) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true) + let refused = "(not in \(logFile.path): not locked within 1s (lockf exit 75))" + let lines = agent.lastStderr.components(separatedBy: "\n").filter { $0.contains("backstop: ") } + XCTAssertFalse(lines.isEmpty, agent.lastStderr) + XCTAssertTrue(lines.allSatisfy { $0.hasSuffix(refused) }, agent.lastStderr) + XCTAssertTrue(lines.contains { $0.contains("the recovery lock file \(lockFile.path), or the log file \(logFile.path). Sleep is restored anyway") }, agent.lastStderr) + + holder.release() + try agent.clearCalls() + try await runAgent(expecting: 1) + XCTAssertTrue(logText().hasPrefix(before + "\n"), "the line cut short stays as it was: \(logText())") + XCTAssertEqual(count(record, in: logFile), 1) + XCTAssertTrue(logText().contains("its end is recorded in the log file \(logFile.path) instead"), logText()) + XCTAssertEqual(h.store.sessionEndRecordedInLog(), "insomnia.log") + } + + // MARK: Other sessions and ordinary ends + + /// A crash beside a record of an earlier session's bytes and lines that + /// are no whole record of this one resumes: none of them ends it. The + /// agent then checks the session as usual and keeps it. + func testACrashResumesBesideRecordsOfOtherBytesAndLinesThatAreNoWholeRecord() async throws { + let first = h.makeManager() + await first.reconcile() + await first.start(duration: 3600) + let record = line(of: try sessionBytes()) + let other = line(of: Data("an earlier session.json".utf8)) + try append("\(other)\n\(record.dropLast(2))\nx \(record)\n\(record)\r\n\(record) \n", to: logFile) + try Data("\(other)\n".utf8).write(to: rotatedLog) + XCTAssertNil(h.store.sessionEndRecordedInLog()) + + let before = h.guardFake.calls.count + let crashed = h.makeManager() + await crashed.reconcile() + XCTAssertTrue(crashed.isActive, logText()) + XCTAssertTrue(sleepHeldAgain(since: before)) + + let alive = AppAliveLock(url: h.home.paths.appAliveFile) + XCTAssertTrue(try alive.tryAcquire()) + defer { alive.release() } + try await runAgent(expecting: 0) + XCTAssertNotNil(try h.store.loadSession()) + XCTAssertTrue(agent.calls.contains("pmset -g batt")) + XCTAssertFalse(logText().contains("already ended"), logText()) + } + + /// Ends that can remove session.json, or record the end in + /// ended-session.json, write no record to the log: the app's, and the + /// agent's. + func testOrdinaryEndsWriteNoRecordToTheLog() async throws { + let m = h.makeManager() + await m.reconcile() + await m.start(duration: 3600) + _ = await m.end(reason: .user) + XCTAssertNil(try h.store.loadSession()) + + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + try await runAgent(expecting: 0) + XCTAssertNil(try h.store.loadSession()) + XCTAssertTrue(agent.calls.contains(agent.restoreCall)) + + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + await m.start(duration: 3600) + try setImmutable(h.home.paths.sessionFile, true) + try await runAgent(expecting: 1) + XCTAssertTrue(h.store.sessionEndIsRecorded(), "recorded in ended-session.json") + XCTAssertFalse(logText().contains(LogEndRecord.tag), logText()) + } +} + +/// Lines written to insomnia.log from another thread while a test runs +/// the agent, as the app logs from any thread: every 25th line takes the +/// recovery lock when it is free and rotates a log made longer than the +/// cap first. +private final class BesideTheAgent: @unchecked Sendable { + private let mutex = NSLock() + private var stopped = false + private var lines = 0 + private var rotations = 0 + private let done = DispatchSemaphore(value: 0) + let paths: Paths + + init(paths: Paths) { self.paths = paths } + + func start() { + Thread.detachNewThread { [self] in + var n = 0 + while !mutex.withLock({ stopped }) { + Log.append(level: "info", "a line beside the agent \(n)", paths: paths) + n += 1 + if n % 25 == 0, let handle = try? RecoveryLock(url: paths.recoveryLock).tryAcquire() { + LogEndRecordTests.appendFiller(to: paths.logFile) + RecoveryLock.$held.withValue(handle) { Log.append(level: "info", "a rotation beside the agent", paths: paths) } + handle.release() + mutex.withLock { rotations += 1 } + } + usleep(2000) + } + mutex.withLock { lines = n } + done.signal() + } + } + + /// Stops the thread and waits for it. Returns how many lines it wrote + /// and how many times it took the lock to rotate. + func stop() -> (lines: Int, rotations: Int) { + mutex.withLock { stopped = true } + done.wait() + return mutex.withLock { (lines, rotations) } + } +} diff --git a/Tests/InsomniaTests/LoginItemTests.swift b/Tests/InsomniaTests/LoginItemTests.swift index e91169ce..a45f1e55 100644 --- a/Tests/InsomniaTests/LoginItemTests.swift +++ b/Tests/InsomniaTests/LoginItemTests.swift @@ -470,7 +470,7 @@ final class LoginItemTests: XCTestCase { /// That reads as a different install, and the same file read twice /// reads the same. func testAReinstallOfTheSameBuildAtTheSamePathIsADifferentInstall() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("loginitem-\(UUID().uuidString)") + let dir = ProcessTestHome.temporaryDirectory.appendingPathComponent("loginitem-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let exe = dir.appendingPathComponent("Insomnia").path diff --git a/Tests/InsomniaTests/OwnedChild.swift b/Tests/InsomniaTests/OwnedChild.swift new file mode 100644 index 00000000..2eeefd53 --- /dev/null +++ b/Tests/InsomniaTests/OwnedChild.swift @@ -0,0 +1,339 @@ +import Darwin +import Foundation + +/// Why an owned child could not be started. +struct ChildError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } +} + +/// A child started by `spawnOwnedChild`. Nothing in the test process reaps +/// it but `wait`: Foundation reaps only the Processes it started, and +/// nothing else here calls waitpid. Until `wait`, its pid stays its own +/// even after it exits (as a zombie), so `signal` reaches this child or +/// nothing, never a process that reused the pid. +final class OwnedChild { + let pid: pid_t + /// Started as the leader of a process group of its own (see spawnOwnedChild). + let leadsGroup: Bool + private(set) var status: Int32? + + init(pid: pid_t, leadsGroup: Bool) { + self.pid = pid + self.leadsGroup = leadsGroup + } + + /// Whether the child has exited, checked without reaping it. + var hasExited: Bool { + guard status == nil else { return true } + var info = siginfo_t() + return waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid == pid + } + + /// Sends `sig` to the child. Refuses with -1 once `wait` has reaped + /// it, since the pid may then belong to another process. + func signal(_ sig: Int32) -> Int32 { + guard status == nil else { return -1 } + return kill(pid, sig) + } + + /// Sends `sig` to every process in the group the child leads, the + /// way launchd signals what is left of a job's process group once + /// its main process has exited. Only for a child spawned with + /// `ownProcessGroup`, so the group holds nothing but the child and + /// what it started. Refuses with -1 once `wait` has reaped the + /// child: until then its pid, the group's id, cannot name another + /// process or group. + func signalGroup(_ sig: Int32) -> Int32 { + guard leadsGroup, status == nil else { return -1 } + return killpg(pid, sig) + } + + /// Waits for the child to exit, reaps it, and returns its wait + /// status (-1 if waitpid failed). + @discardableResult + func wait() -> Int32 { + if let status { return status } + var raw: Int32 = 0 + var reaped: pid_t + repeat { reaped = waitpid(pid, &raw, 0) } while reaped == -1 && errno == EINTR + let result = reaped == pid ? raw : -1 + status = result + return result + } + + /// Waits at most `seconds` for the child to exit, then reaps it and + /// returns its wait status, as `wait` does. Returns nil, with the + /// child neither reaped nor signalled, when it has not exited by then. + func wait(within seconds: TimeInterval) -> Int32? { + let deadline = Date().addingTimeInterval(seconds) + while !hasExited { + if Date() >= deadline { return nil } + Thread.sleep(forTimeInterval: 0.005) + } + return wait() + } + + /// A wait status in words: "exit N" or "signal N". + static func describe(_ raw: Int32) -> String { + if raw == -1 { return "no status (waitpid failed)" } + return raw & 0x7f == 0 ? "exit \((raw >> 8) & 0xff)" : "signal \(raw & 0x7f)" + } +} + +/// Starts `path` with `arguments` (argv[0] first), `environment` and +/// working directory `directory`, and returns without waiting for it. +/// Standard input, output and error are the given descriptors, or +/// /dev/null for each one left nil. Like a Process, the child gets no other +/// descriptor of the test process, an empty signal mask and default signal +/// actions. With `ownProcessGroup` it leads a new process group, as launchd +/// starts a job, instead of joining the test's. Each setup call is checked: +/// one that fails throws before anything starts, with what was set up so +/// far released. +func spawnOwnedChild( + _ path: String, _ arguments: [String], environment: [String: String], directory: URL, + standardInput: Int32? = nil, standardOutput: Int32? = nil, standardError: Int32? = nil, + ownProcessGroup: Bool = false +) throws -> OwnedChild { + func check(_ rc: Int32, _ call: String) throws { + guard rc == 0 else { throw ChildError("\(call) failed (\(rc)) for \(path); nothing started") } + } + var actions: posix_spawn_file_actions_t? + try check(posix_spawn_file_actions_init(&actions), "posix_spawn_file_actions_init") + defer { posix_spawn_file_actions_destroy(&actions) } + for (target, given, flags) in [(Int32(0), standardInput, O_RDONLY), (1, standardOutput, O_WRONLY), (2, standardError, O_WRONLY)] { + if let given { + try check(posix_spawn_file_actions_adddup2(&actions, given, target), "posix_spawn_file_actions_adddup2 \(target)") + } else { + try check(posix_spawn_file_actions_addopen(&actions, target, "/dev/null", flags, 0), "posix_spawn_file_actions_addopen \(target)") + } + } + try check(posix_spawn_file_actions_addchdir(&actions, directory.path), "posix_spawn_file_actions_addchdir") + + var attr: posix_spawnattr_t? + try check(posix_spawnattr_init(&attr), "posix_spawnattr_init") + defer { posix_spawnattr_destroy(&attr) } + var mask = sigset_t() + try check(sigemptyset(&mask), "sigemptyset") + var defaults = sigset_t() + try check(sigfillset(&defaults), "sigfillset") + try check(sigdelset(&defaults, SIGKILL), "sigdelset SIGKILL") + try check(sigdelset(&defaults, SIGSTOP), "sigdelset SIGSTOP") + try check(posix_spawnattr_setsigmask(&attr, &mask), "posix_spawnattr_setsigmask") + try check(posix_spawnattr_setsigdefault(&attr, &defaults), "posix_spawnattr_setsigdefault") + var flags = POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF + if ownProcessGroup { + try check(posix_spawnattr_setpgroup(&attr, 0), "posix_spawnattr_setpgroup") + flags |= POSIX_SPAWN_SETPGROUP + } + try check(posix_spawnattr_setflags(&attr, Int16(flags)), "posix_spawnattr_setflags") + + let argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } + [nil] + let envp: [UnsafeMutablePointer?] = environment.map { strdup("\($0.key)=\($0.value)") } + [nil] + defer { (argv + envp).forEach { free($0) } } + guard argv.dropLast().allSatisfy({ $0 != nil }), envp.dropLast().allSatisfy({ $0 != nil }) else { + throw ChildError("strdup failed for \(path); nothing started") + } + + var pid: pid_t = 0 + let spawned = posix_spawn(&pid, path, &actions, &attr, argv, envp) + guard spawned == 0, pid > 0 else { throw ChildError("posix_spawn \(path) failed: \(spawned)") } + return OwnedChild(pid: pid, leadsGroup: ownProcessGroup) +} + +/// `/usr/bin/lockf -k -t /bin/cat`: holds `file`'s lock from +/// a process of its own, the way a running app or a concurrent backstop.sh +/// would, until stopped. lockf leads a process group of its own. cat reads +/// a pipe only the test writes to and writes to a pipe only the test +/// reads; lockf's own messages go to `errors`. lockf runs cat only once it +/// holds the lock, so a line cat prints back shows the lock held. +/// +/// `stop` closes cat's input: cat ends, lockf reaps it and exits with its +/// status, and `stop` reaps lockf, so nothing outlives the holder. If lockf +/// has not exited in time, `stop` sends SIGINT to the group. lockf ignores +/// SIGINT and SIGQUIT once it has forked its command (its code calls +/// signal(SIGINT, SIG_IGN) right after the fork), so cat stops, lockf +/// reaps it and exits 70 (EX_SOFTWARE), and `stop` reaps lockf. No other +/// signal is sent, never SIGKILL. A holder that still has not exited stays +/// unsettled, and whoever made it keeps the folders it uses. +final class LockfHolder { + let child: OwnedChild + /// lockf's own messages (standard error). + let errors: URL + /// The test's end of cat's input, nonblocking; -1 once closed. + private var input: Int32 + /// The test's end of cat's output, nonblocking; -1 once closed. + private var output: Int32 + private var printed: [UInt8] = [] + /// lockf's wait status once `stop` has reaped it. + private(set) var waitStatus: Int32? + /// What went wrong, in order. Empty when the holder ended as it should. + private(set) var problems: [String] = [] + + init(file: URL, wait seconds: Int, errors: URL, environment: [String: String], directory: URL) throws { + self.errors = errors + guard FileManager.default.createFile(atPath: errors.path, contents: nil) else { + throw ChildError("could not create \(errors.path) for lockf's messages") + } + let errorsFD = open(errors.path, O_WRONLY | O_APPEND | O_CLOEXEC) + guard errorsFD >= 0 else { throw ChildError("could not open \(errors.path): errno \(errno)") } + var toCat: [Int32] = [-1, -1] + var fromCat: [Int32] = [-1, -1] + func release(_ fds: [Int32]) { for fd in fds where fd >= 0 { close(fd) } } + guard pipe(&toCat) == 0 else { + let err = errno + release([errorsFD]) + throw ChildError("could not make cat's input pipe: errno \(err)") + } + guard pipe(&fromCat) == 0 else { + let err = errno + release([errorsFD] + toCat) + throw ChildError("could not make cat's output pipe: errno \(err)") + } + // The test's ends: close-on-exec, so no other child gets them, and + // nonblocking, so no write or read here waits. + for fd in [toCat[1], fromCat[0]] { + let flags = fcntl(fd, F_GETFL) + guard fcntl(fd, F_SETFD, FD_CLOEXEC) == 0, flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 else { + let err = errno + release([errorsFD] + toCat + fromCat) + throw ChildError("could not set up the test's end of a pipe to cat: errno \(err)") + } + } + do { + child = try spawnOwnedChild( + "/usr/bin/lockf", ["/usr/bin/lockf", "-k", "-t", String(seconds), file.path, "/bin/cat"], + environment: environment, directory: directory, + standardInput: toCat[0], standardOutput: fromCat[1], standardError: errorsFD, ownProcessGroup: true) + } catch { + release([errorsFD] + toCat + fromCat) + throw error + } + input = toCat[1] + output = fromCat[0] + // The child's ends are its own now. + for (fd, name) in [(toCat[0], "cat's input"), (fromCat[1], "cat's output"), (errorsFD, "lockf's errors")] where close(fd) != 0 { + problems.append("closing the test's copy of \(name): errno \(errno)") + } + } + + /// Writes a line to cat and returns true once cat has printed it back + /// within `seconds`: lockf holds the lock and runs cat. False, with the + /// reason in `problems`, when lockf exited without running cat (it did + /// not get the lock), the time ran out, or a write or read failed. + func held(within seconds: TimeInterval) -> Bool { + let line = Array("held\n".utf8) + guard input >= 0, output >= 0 else { + problems.append("asked whether the lock is held after stop") + return false + } + let written = line.withUnsafeBytes { write(input, $0.baseAddress, $0.count) } + guard written == line.count else { + problems.append("writing to cat: \(written) of \(line.count) bytes, errno \(errno)") + return false + } + let deadline = Date().addingTimeInterval(seconds) + var buffer = [UInt8](repeating: 0, count: 64) + while true { + let left = deadline.timeIntervalSinceNow + guard left > 0 else { + problems.append("cat printed \(printed.count) of \(line.count) bytes back within \(seconds) s") + return false + } + var ready = pollfd(fd: output, events: Int16(POLLIN), revents: 0) + let polled = poll(&ready, 1, Int32(min(left, 1) * 1000) + 1) + if polled < 0 { + if errno == EINTR { continue } + problems.append("poll on cat's output: errno \(errno)") + return false + } + if polled == 0 { continue } + let got = read(output, &buffer, buffer.count) + if got > 0 { + printed += buffer[0..= line.count || !line.starts(with: printed) { + problems.append("cat printed \(String(decoding: printed, as: UTF8.self).debugDescription)") + return false + } + } else if got == 0 { + problems.append("cat's output ended before the line came back: lockf exited without running cat") + return false + } else if errno != EAGAIN && errno != EINTR { + problems.append("reading cat's output: errno \(errno)") + return false + } + } + } + + /// Writes to cat, without reading what it prints, until neither pipe + /// takes more for `seconds`: cat is then stuck writing to a full pipe, + /// and closing its input no longer ends it. Returns the bytes written. + /// For the test of `stop`'s SIGINT. + func jam(quietFor seconds: TimeInterval) -> Int { + let chunk = [UInt8](repeating: 0x2e, count: 4096) + var total = 0 + var quietSince = Date() + while input >= 0, Date().timeIntervalSince(quietSince) < seconds { + let n = chunk.withUnsafeBytes { write(input, $0.baseAddress, $0.count) } + if n > 0 { + total += n + quietSince = Date() + } else if n < 0 && errno != EAGAIN && errno != EINTR { + problems.append("writing to cat: errno \(errno)") + return total + } else { + Thread.sleep(forTimeInterval: 0.01) + } + } + return total + } + + /// Ends the holder and returns lockf's exit status once it has exited + /// and been reaped: 0 once cat has read the end of its input and lockf + /// has reaped it, 70 when cat had to be interrupted, 128 plus the signal + /// if lockf itself was ended by one. -1 when lockf had not exited + /// `seconds` after its input closed nor `grace` after SIGINT: it is + /// then unsettled (`isSettled` false). A lockf still waiting for the + /// lock gives up on its own at its -t limit, as it does without the + /// lock. Every call after the first returns the same. + @discardableResult + func stop(within seconds: TimeInterval = 15, grace: TimeInterval = 5) -> Int32 { + if let waitStatus { return Self.code(waitStatus) } + if input >= 0 { + if close(input) != 0 { problems.append("closing cat's input: errno \(errno)") } + input = -1 + } + var raw = child.wait(within: seconds) + if raw == nil { + let sent = child.signalGroup(SIGINT) + problems.append("lockf had not exited \(seconds) s after cat's input closed; SIGINT to its group: \(sent == 0 ? "sent" : "errno \(errno)")") + raw = child.wait(within: grace) + } + guard let raw else { + problems.append("lockf had not exited \(grace) s after SIGINT either: left as it is, unsettled") + return -1 + } + guard raw != -1 else { + problems.append("waitpid failed for lockf \(child.pid): unsettled") + return -1 + } + if output >= 0 { + if close(output) != 0 { problems.append("closing cat's output: errno \(errno)") } + output = -1 + } + if raw & 0x7f != 0 { problems.append("lockf ended by \(OwnedChild.describe(raw))") } + waitStatus = raw + return Self.code(raw) + } + + /// Whether `stop` has reaped lockf. + var isSettled: Bool { waitStatus != nil } + + /// lockf's messages so far, or why they could not be read. + func errorText() -> String { + do { return try String(contentsOf: errors, encoding: .utf8) } catch { return "(could not read \(errors.path): \(error))" } + } + + private static func code(_ raw: Int32) -> Int32 { raw & 0x7f == 0 ? (raw >> 8) & 0xff : 128 + (raw & 0x7f) } +} diff --git a/Tests/InsomniaTests/OwnedChildTests.swift b/Tests/InsomniaTests/OwnedChildTests.swift new file mode 100644 index 00000000..f549776e --- /dev/null +++ b/Tests/InsomniaTests/OwnedChildTests.swift @@ -0,0 +1,133 @@ +import Darwin +import Foundation +import XCTest + +/// The fixtures that start and end processes for the lock and log tests: +/// LockfHolder ends every process it starts and reaps it, and +/// PatchedBackstop.LogWriters serves each request before it settles. +final class OwnedChildTests: XCTestCase { + private var dir: URL! + private var holders: [LockfHolder] = [] + + override func setUpWithError() throws { + dir = ProcessTestHome.temporaryDirectory.appendingPathComponent("owned-child-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + /// A holder that has not exited keeps the folder it locks in. + override func tearDown() { + for holder in holders where !holder.isSettled { holder.stop() } + let unsettled = holders.filter { !$0.isSettled } + guard unsettled.isEmpty else { + XCTFail("keeping \(dir.path) for lockf holders that did not exit: \(unsettled.map(\.problems))") + return + } + try? FileManager.default.removeItem(at: dir) + } + + private var lockFile: URL { dir.appendingPathComponent("lock") } + + private func holder(wait: Int) throws -> LockfHolder { + let holder = try LockfHolder( + file: lockFile, wait: wait, errors: dir.appendingPathComponent("errors-\(holders.count)"), + environment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"], directory: dir) + holders.append(holder) + return holder + } + + /// Whether this process can take `lockFile`'s lock now, as lockf does. + private func lockIsFree() throws -> Bool { + let fd = open(lockFile.path, O_RDONLY | O_CREAT | O_CLOEXEC, 0o600) + guard fd >= 0 else { throw ChildError("could not open \(lockFile.path): errno \(errno)") } + defer { close(fd) } + if flock(fd, LOCK_EX | LOCK_NB) == 0 { return true } + guard errno == EWOULDBLOCK else { throw ChildError("flock \(lockFile.path): errno \(errno)") } + return false + } + + func testAHolderStopsOnceItsInputEndsAndIsReaped() throws { + let holder = try holder(wait: 0) + XCTAssertTrue(holder.held(within: 5), "\(holder.problems) \(holder.errorText())") + XCTAssertFalse(try lockIsFree()) + XCTAssertEqual(holder.stop(), 0, "\(holder.problems)") + XCTAssertTrue(holder.isSettled) + XCTAssertEqual(holder.problems, []) + XCTAssertEqual(holder.child.signal(0), -1, "a reaped holder is not signalled") + XCTAssertTrue(try lockIsFree()) + XCTAssertEqual(holder.stop(), 0, "a second stop returns the same") + } + + /// cat stuck writing to a full pipe does not end when its input + /// closes. SIGINT to the group ends cat; lockf, which ignores SIGINT + /// once it has run its command, reaps it and exits 70, and is reaped. + func testAHolderWhoseCommandCannotFinishIsInterruptedAndReaped() throws { + let holder = try holder(wait: 0) + XCTAssertTrue(holder.held(within: 5), "\(holder.problems) \(holder.errorText())") + XCTAssertGreaterThan(holder.jam(quietFor: 0.3), 0, "\(holder.problems)") + XCTAssertEqual(holder.stop(within: 0.5, grace: 5), 70, "\(holder.problems)") + XCTAssertTrue(holder.isSettled) + XCTAssertEqual(holder.problems.count, 1, "\(holder.problems)") + XCTAssertTrue(holder.problems.first?.contains("SIGINT to its group: sent") ?? false, "\(holder.problems)") + XCTAssertTrue(try lockIsFree()) + } + + /// A holder that never gets the lock prints nothing back, and lockf + /// gives up at its -t limit with 75. + func testAHolderThatNeverGetsTheLockGivesUpAtItsLimit() throws { + let fd = open(lockFile.path, O_RDONLY | O_CREAT | O_CLOEXEC, 0o600) + XCTAssertGreaterThanOrEqual(fd, 0) + defer { close(fd) } + XCTAssertEqual(flock(fd, LOCK_EX | LOCK_NB), 0) + let holder = try holder(wait: 1) + XCTAssertFalse(holder.held(within: 0.3)) + XCTAssertEqual(holder.stop(), 75, "\(holder.problems)") + XCTAssertTrue(holder.isSettled) + } + + /// The writer serves a request: it takes the log's lock, writes + /// `held sh`, acknowledges, writes `ort` a second later and lets go. + /// Two settles at once get one outcome. + func testTheLogWriterServesARequestAndSettlesOnce() async throws { + let log = dir.appendingPathComponent("insomnia.log") + XCTAssertTrue(FileManager.default.createFile(atPath: log.path, contents: nil)) + let writers = try PatchedBackstop.LogWriters(dir: dir.appendingPathComponent("writers", isDirectory: true), log: log) + // As the fake does: made under a dot name, then renamed. + let made = writers.requests.appendingPathComponent(".r1") + try Data().write(to: made) + try FileManager.default.moveItem(at: made, to: writers.requests.appendingPathComponent("r1")) + let ack = writers.acks.appendingPathComponent("r1") + let deadline = Date().addingTimeInterval(5) + while !FileManager.default.fileExists(atPath: ack.path), Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: ack.path)) + let fd = open(log.path, O_RDONLY | O_CLOEXEC) + XCTAssertGreaterThanOrEqual(fd, 0) + XCTAssertNotEqual(flock(fd, LOCK_EX | LOCK_NB), 0, "the writer holds the log's lock once it acknowledges") + close(fd) + try Data().write(to: writers.seen.appendingPathComponent("r1")) + + async let first = writers.settle() + async let second = writers.settle() + let (a, b) = try await (first, second) + XCTAssertEqual(a, [0]) + XCTAssertEqual(b, [0]) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "held short") + } + + /// A request the fake did not see answered in time fails the settle. + func testTheLogWriterSettleFailsForALateAcknowledgment() async throws { + let log = dir.appendingPathComponent("insomnia.log") + XCTAssertTrue(FileManager.default.createFile(atPath: log.path, contents: nil)) + let writers = try PatchedBackstop.LogWriters(dir: dir.appendingPathComponent("writers", isDirectory: true), log: log) + try Data().write(to: writers.late.appendingPathComponent("r1")) + do { + _ = try await writers.settle() + XCTFail("settled with a late acknowledgment") + } catch let error as PatchedBackstop.LogWriters.Unsettled { + XCTFail("\(error)") + } catch { + XCTAssertTrue("\(error)".contains("late [\"r1\"]"), "\(error)") + } + } +} diff --git a/Tests/InsomniaTests/OwnerOnlyTests.swift b/Tests/InsomniaTests/OwnerOnlyTests.swift index 46feea77..5315c8e8 100644 --- a/Tests/InsomniaTests/OwnerOnlyTests.swift +++ b/Tests/InsomniaTests/OwnerOnlyTests.swift @@ -52,17 +52,30 @@ final class OwnerOnlyTests: XCTestCase { XCTAssertTrue(text.hasSuffix("insomnia: new line\n"), text) } + /// insomnia.log rotates only under the recovery lock, as a rotation + /// must not run while the lock's holder writes and reads back an end + /// record (`LogEndRecord`). Without it, a line past the cap is appended + /// and the rotation waits for a line written under the lock. func testLogPastTheCapRotatesToDotOneAndStartsAFreshFile() throws { let log = home.paths.logFile let rotated = OwnerOnly.rotated(log) XCTAssertEqual(rotated.lastPathComponent, "insomnia.log.1") - try FileManager.default.createDirectory(at: home.paths.logs, withIntermediateDirectories: true) + try home.paths.createDirectories() let first = Data(repeating: UInt8(ascii: "a"), count: Int(OwnerOnly.maxLogBytes) + 1) try first.write(to: log) - Log.append(level: "info", "after first rotation", paths: home.paths) + Log.append(level: "info", "without the lock", paths: home.paths) + XCTAssertFalse(FileManager.default.fileExists(atPath: rotated.path), "rotated without the recovery lock") + XCTAssertTrue(try String(contentsOf: log, encoding: .utf8).hasSuffix("insomnia: without the lock\n")) - XCTAssertEqual(try Data(contentsOf: rotated), first, "the full log was not moved aside intact") + let handle = try XCTUnwrap(try RecoveryLock(url: home.paths.recoveryLock).tryAcquire()) + defer { handle.release() } + let full = try Data(contentsOf: log) + RecoveryLock.$held.withValue(handle) { + Log.append(level: "info", "after first rotation", paths: home.paths) + } + + XCTAssertEqual(try Data(contentsOf: rotated), full, "the full log was not moved aside intact") let fresh = try String(contentsOf: log, encoding: .utf8) XCTAssertTrue(fresh.hasSuffix("insomnia: after first rotation\n"), fresh) XCTAssertLessThan(fresh.utf8.count, 200, "the new file must hold only the new line") @@ -71,26 +84,65 @@ final class OwnerOnlyTests: XCTestCase { // The next rotation replaces .1; nothing becomes .2. let second = Data(repeating: UInt8(ascii: "b"), count: Int(OwnerOnly.maxLogBytes) + 1) try second.write(to: log) - Log.append(level: "info", "after second rotation", paths: home.paths) + RecoveryLock.$held.withValue(handle) { + Log.append(level: "info", "after second rotation", paths: home.paths) + } XCTAssertEqual(try Data(contentsOf: rotated), second) XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: home.paths.logs.path).sorted(), ["insomnia.log", "insomnia.log.1"]) + + // A handle released, or on another file, no longer counts. + try second.write(to: log) + handle.release() + RecoveryLock.$held.withValue(handle) { + Log.append(level: "info", "after the release", paths: home.paths) + } + XCTAssertEqual(try Data(contentsOf: rotated), second, "rotated with a released handle") } /// Exactly at the cap nothing moves; one byte over, the next append rotates. func testRotationHappensOnlyOncePastTheCap() throws { let log = home.paths.logs.appendingPathComponent("small.log") - try OwnerOnly.appendToLog("0123456789", at: log, maxBytes: 10) + try OwnerOnly.appendToLog("012345678\n", at: log, maxBytes: 10) try OwnerOnly.appendToLog("x", at: log, maxBytes: 10) XCTAssertFalse(FileManager.default.fileExists(atPath: OwnerOnly.rotated(log).path), "rotated at, not past, the cap") - XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "0123456789x") + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "012345678\nx") try OwnerOnly.appendToLog("y", at: log, maxBytes: 10) - XCTAssertEqual(try String(contentsOf: OwnerOnly.rotated(log), encoding: .utf8), "0123456789x") - XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "y") + XCTAssertEqual(try String(contentsOf: OwnerOnly.rotated(log), encoding: .utf8), "012345678\nx") + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "y", "a new file takes no newline first") XCTAssertEqual(try mode(OwnerOnly.rotated(log)), 0o600, "the rotated file keeps the owner-only mode") } + /// A log whose last line was cut short (no newline at the end) gets a + /// newline before the next line, in the same write, so the line cut + /// short stays whole on its own line: one that ends in a newline, an + /// empty file and a file this user may only write to are appended to + /// as they are, except that a last byte that cannot be read counts as + /// cut short. + func testALineCutShortIsEndedBeforeTheNextLine() throws { + try FileManager.default.createDirectory(at: home.paths.logs, withIntermediateDirectories: true) + let log = home.paths.logs.appendingPathComponent("cut.log") + try Data("whole\ncut sh".utf8).write(to: log) + try OwnerOnly.appendToLog("next\n", at: log) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "whole\ncut sh\nnext\n") + try OwnerOnly.appendToLog("after a newline\n", at: log) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "whole\ncut sh\nnext\nafter a newline\n") + + let empty = home.paths.logs.appendingPathComponent("empty.log") + try Data().write(to: empty) + try OwnerOnly.appendToLog("first\n", at: empty) + XCTAssertEqual(try String(contentsOf: empty, encoding: .utf8), "first\n") + + let writeOnly = home.paths.logs.appendingPathComponent("write-only.log") + try Data("ends\n".utf8).write(to: writeOnly) + XCTAssertEqual(chmod(writeOnly.path, 0o200), 0) + defer { _ = chmod(writeOnly.path, 0o600) } + try OwnerOnly.appendToLog("line\n", at: writeOnly) + XCTAssertEqual(chmod(writeOnly.path, 0o600), 0) + XCTAssertEqual(try String(contentsOf: writeOnly, encoding: .utf8), "ends\n\nline\n", "its last byte could not be read") + } + // MARK: Rotation races and failures that must be reported /// Two processes can both find the log oversized. The one that gets to @@ -138,6 +190,98 @@ final class OwnerOnlyTests: XCTestCase { XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "first\nsecond\n") } + /// Every writer of the log holds flock(2) on it from its look at the + /// last byte to the end of its write. While another writer holds it + /// with a line cut short, the rest of that line still to come, an + /// append writes nothing. Once that writer has written the rest, still + /// without a newline, and let go, the append reads the last byte under + /// the lock and puts a newline first. + func testAnAppendWaitsForTheLogsLockAndStartsAfterWhatItsHolderLeft() throws { + let log = home.paths.logs.appendingPathComponent("held.log") + try OwnerOnly.appendToLog("a line\n", at: log) + let holder = try LogLockHolder(log) + defer { holder.release() } + try holder.write("cut sh") + let appended = DispatchSemaphore(value: 0) + let failure = Locked(nil) + DispatchQueue.global().async { + do { + try OwnerOnly.appendToLog("next\n", at: log, lockTimeout: 30) + } catch { + failure.value = "\(error)" + } + appended.signal() + } + XCTAssertEqual(appended.wait(timeout: .now() + 0.5), .timedOut, "the append went ahead under another writer's lock") + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "a line\ncut sh") + + try holder.write("ort") + holder.release() + + XCTAssertEqual(appended.wait(timeout: .now() + 30), .success) + XCTAssertNil(failure.value) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "a line\ncut short\nnext\n") + } + + /// A line that gets no lock in time writes nothing: `appendToLog` + /// throws `.busy`, and `Log.append` keeps the line and writes it before + /// the next one, in order. A log held for good keeps at most + /// `Log.maxDeferredBytes` of lines waiting; the oldest go first. + func testALineThatGetsNoLockIsWrittenBeforeTheNextOne() throws { + let log = home.paths.logFile + Log.append(level: "info", "first", paths: home.paths) + let holder = try LogLockHolder(log) + defer { holder.release() } + let before = try Data(contentsOf: log) + + XCTAssertThrowsError(try OwnerOnly.appendToLog("x\n", at: log, lockTimeout: 0.2)) { error in + guard case .busy(let path, _)? = error as? OwnerOnlyError else { return XCTFail("\(error)") } + XCTAssertEqual(path, log.path) + } + Log.append(level: "info", "second", paths: home.paths, lockTimeout: 0.2) + Log.append(level: "info", "third", paths: home.paths, lockTimeout: 0.2) + XCTAssertEqual(try Data(contentsOf: log), before, "written while another writer held the lock") + holder.release() + Log.append(level: "info", "fourth", paths: home.paths) + + func messages() throws -> [String] { + try String(contentsOf: log, encoding: .utf8).split(separator: "\n").map { + String($0.split(separator: " ", maxSplits: 1).last ?? "") + } + } + XCTAssertEqual(try messages(), ["[info] insomnia: first", "[info] insomnia: second", "[info] insomnia: third", "[info] insomnia: fourth"]) + + let again = try LogLockHolder(log) + defer { again.release() } + // Three of these lines are just past the limit, two are well within it. + let long = String(repeating: "x", count: Log.maxDeferredBytes / 3) + for n in 1...3 { Log.append(level: "info", "\(n) \(long)", paths: home.paths, lockTimeout: 0.05) } + again.release() + Log.append(level: "info", "after", paths: home.paths) + + XCTAssertEqual(try messages().dropFirst(4).map { String($0.prefix(18)) }, ["[info] insomnia: 2", "[info] insomnia: 3", "[info] insomnia: a"]) + } + + /// A write that writes nothing ends the append with an error instead of + /// trying again forever. + func testAWriteThatWritesNothingEndsTheAppend() throws { + let log = home.paths.logs.appendingPathComponent("stalled.log") + try OwnerOnly.appendToLog("a line\n", at: log) + let calls = Locked(0) + OwnerOnly.logWriteForTesting = { _, _, _ in + calls.value += 1 + return 0 + } + defer { OwnerOnly.logWriteForTesting = nil } + + XCTAssertThrowsError(try OwnerOnly.appendToLog("next\n", at: log)) { error in + guard case .write(let path, _)? = error as? OwnerOnlyError else { return XCTFail("\(error)") } + XCTAssertEqual(path, log.path) + } + XCTAssertEqual(calls.value, 1) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "a line\n") + } + /// A legacy 0644 log that is already past the cap is tightened before it /// becomes `.1`, so the retained copy is owner-only too. func testLooseLogPastTheCapIsTightenedBeforeItIsRotated() throws { @@ -157,7 +301,7 @@ final class OwnerOnlyTests: XCTestCase { /// longer holds; the line itself is not lost. func testFailedRotationIsThrownAfterTheLineIsWritten() throws { let log = home.paths.logs.appendingPathComponent("stuck.log") - try OwnerOnly.appendToLog("0123456789A", at: log, maxBytes: 10) + try OwnerOnly.appendToLog("0123456789\n", at: log, maxBytes: 10) try FileManager.default.createDirectory(at: OwnerOnly.rotated(log), withIntermediateDirectories: true) XCTAssertThrowsError(try OwnerOnly.appendToLog("B", at: log, maxBytes: 10)) { error in @@ -165,7 +309,7 @@ final class OwnerOnlyTests: XCTestCase { XCTAssertEqual(path, log.path) XCTAssertTrue(error.localizedDescription.hasPrefix("could not rotate \(log.path) to \(log.path).1: "), error.localizedDescription) } - XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "0123456789AB") + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "0123456789\nB") } /// A file this user cannot chmod (here: immutable) is still read, and @@ -370,6 +514,42 @@ final class HandoffsLogPermissionTests: XCTestCase { /// Holds a worker until the test decides. Only `open` lets it through; /// `close`, or no decision before the wait runs out, makes `pass` throw so /// the worker stops where it is. The first decision stands. +/// Holds flock(2) on a log through a descriptor of its own, as another +/// writer of it would (backstop.sh, the LaunchAgent): it can write while it +/// holds the lock, and lets go when released. +final class LogLockHolder: @unchecked Sendable { + private let mutex = NSLock() + private var fd: Int32 + + init(_ url: URL) throws { + fd = open(url.path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0o600) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { + let err = errno + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: err) ?? .EIO) + } + } + + func write(_ text: String) throws { + try mutex.withLock { + let data = Data(text.utf8) + let n = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress!, $0.count) } + guard n == data.count else { throw POSIXError(.EIO) } + } + } + + func release() { + mutex.withLock { + guard fd >= 0 else { return } + close(fd) + fd = -1 + } + } + + deinit { release() } +} + private final class Gate: @unchecked Sendable { struct Closed: Error {} diff --git a/Tests/InsomniaTests/PackagingTests.swift b/Tests/InsomniaTests/PackagingTests.swift index 115d5eaa..ea3861b0 100644 --- a/Tests/InsomniaTests/PackagingTests.swift +++ b/Tests/InsomniaTests/PackagingTests.swift @@ -106,7 +106,7 @@ final class PackagingTests: XCTestCase { private var scratch: URL! override func setUpWithError() throws { - scratch = FileManager.default.temporaryDirectory + scratch = ProcessTestHome.temporaryDirectory .appendingPathComponent("insomnia-packaging-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) } @@ -731,7 +731,7 @@ private struct IconScriptFixture { private let fm = FileManager.default init() throws { - root = FileManager.default.temporaryDirectory.appendingPathComponent("icon-script-\(UUID().uuidString)", isDirectory: true) + root = ProcessTestHome.temporaryDirectory.appendingPathComponent("icon-script-\(UUID().uuidString)", isDirectory: true) for dir in ["scripts", "Resources", "docs/assets", "bin", "tmp"] { try fm.createDirectory(at: root.appendingPathComponent(dir, isDirectory: true), withIntermediateDirectories: true) } diff --git a/Tests/InsomniaTests/PatchedBackstop.swift b/Tests/InsomniaTests/PatchedBackstop.swift new file mode 100644 index 00000000..4822917a --- /dev/null +++ b/Tests/InsomniaTests/PatchedBackstop.swift @@ -0,0 +1,598 @@ +import Darwin +import Foundation +import XCTest +@testable import Insomnia + +/// A copy of backstop.sh whose tools are fakes in `dir`, for tests that run +/// the real agent beside a SessionManager on the same INSOMNIA_HOME: pmset +/// reports an internal battery on battery power at 25% or the level set +/// with `setBattery`, notifyutil the thermal pressure level set with +/// `setThermal`, sudo succeeds. Each call is recorded in dir/calls, and +/// each sudo call also copies state.json as it was at that moment to +/// dir/state-at-sudo (removed when there is none) and lists the names in +/// INSOMNIA_HOME to dir/names-at-sudo and in its Logs folder to +/// dir/logs-at-sudo. The app binary is the one this build made, for +/// `--agent-cutoffs` and `--agent-session-cutoffs` only (`BuiltApp`), +/// unrecorded, so config.json and the journal's cutoffs are read by the +/// app's own decoder as in production; its Info.plist declares those modes +/// and not `--resume-frozen`. +struct PatchedBackstop { + let dir: URL + let script: URL + let home: URL + + struct PatchError: Error, CustomStringConvertible { + let constant: String + let hits: Int + var description: String { "backstop.sh has \(hits) lines setting \(constant), not one" } + } + + init(home: URL, dir: URL) throws { + self.home = home + self.dir = dir + let fm = FileManager.default + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let calls = dir.appendingPathComponent("calls").path + let thermal = dir.appendingPathComponent("thermal").path + let battery = dir.appendingPathComponent("battery").path + let stateAtSudo = dir.appendingPathComponent("state-at-sudo").path + let namesAtSudo = dir.appendingPathComponent("names-at-sudo").path + let logsAtSudo = dir.appendingPathComponent("logs-at-sudo").path + let state = home.appendingPathComponent("state.json").path + try "0".write(toFile: thermal, atomically: true, encoding: .utf8) + try "25".write(toFile: battery, atomically: true, encoding: .utf8) + let fakes: [String: String] = [ + "PMSET": #""" + printf 'pmset %s\n' "$*" >> '\#(calls)' + [[ "$*" == "-g batt" ]] || exit 99 + printf "Now drawing from 'Battery Power'\n -InternalBattery-0 (id=4567)\t%s%%; discharging; 1:00 remaining present: true\n" "$(/bin/cat '\#(battery)')" + """#, + "NOTIFYUTIL": #""" + printf 'notifyutil %s\n' "$*" >> '\#(calls)' + printf 'com.apple.system.thermalpressurelevel %s\n' "$(/bin/cat '\#(thermal)')" + """#, + "SUDO": #""" + printf 'sudo %s\n' "$*" >> '\#(calls)' + /bin/cp '\#(state)' '\#(stateAtSudo)' 2>/dev/null || /bin/rm -f '\#(stateAtSudo)' + /bin/ls -a '\#(home.path)' > '\#(namesAtSudo)' + /bin/ls -a '\#(home.path)/Logs' > '\#(logsAtSudo)' 2>/dev/null || : > '\#(logsAtSudo)' + """#, + "IOREG": "exit 0", + "PS": "exit 1", + "SYSCTL": "echo fake-boot", + "KILL": #"printf 'kill %s\n' "$*" >> '\#(calls)'; exit 1"#, + "DEFAULTS": #"printf 'defaults %s\n' "$*" >> '\#(calls)'; exit 1"#, + "INSOMNIA_BIN": #""" + [[ "${1:-}" == --agent-cutoffs || "${1:-}" == --agent-session-cutoffs ]] && exec '\#(BuiltApp.binary.path)' "$@" + exit 1 + """#, + ] + var constants = [String: String]() + for (name, body) in fakes { + let fake = dir.appendingPathComponent(name.lowercased()) + try "#!/bin/bash\n\(body)\n".write(to: fake, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fake.path) + constants[name] = fake.path + } + let info = dir.appendingPathComponent("Info.plist") + try BuiltApp.infoPlist(agentCutoffsVersion: "\(AgentCutoffsCommand.version)").write(to: info, atomically: true, encoding: .utf8) + constants["INSOMNIA_INFO"] = info.path + let source = try ScriptSource.shared.lines("backstop.sh", setting: Array(constants.keys)) + var lines = source.lines + for (name, value) in constants { + let hits = source.hits[name] ?? [] + guard hits.count == 1 else { throw PatchError(constant: name, hits: hits.count) } + lines[hits[0]] = "\(name)='\(value)'" + } + script = dir.appendingPathComponent("backstop.sh") + try lines.joined(separator: "\n").write(to: script, atomically: true, encoding: .utf8) + } + + func setThermal(_ level: Int) throws { + try "\(level)".write(to: dir.appendingPathComponent("thermal"), atomically: true, encoding: .utf8) + } + + func setBattery(_ percent: Int) throws { + try "\(percent)".write(to: dir.appendingPathComponent("battery"), atomically: true, encoding: .utf8) + } + + struct RunError: Error, CustomStringConvertible { + let description: String + } + + /// What the last run printed on standard output and standard error, + /// kept in `dir` (last.stdout, last.stderr) rather than thrown away. + var lastStdout: String { String(decoding: read("last.stdout") ?? Data(), as: UTF8.self) } + var lastStderr: String { String(decoding: read("last.stderr") ?? Data(), as: UTF8.self) } + + /// The bytes of `name` in `dir`, or nil when there is no such file. A + /// file that is there but cannot be read fails the test instead of + /// reading as missing, so an unread record never passes for no calls. + private func read(_ name: String) -> Data? { + let url = dir.appendingPathComponent(name) + do { + return try Data(contentsOf: url) + } catch CocoaError.fileReadNoSuchFile { + return nil + } catch { + XCTFail("could not read \(url.path): \(error)") + return nil + } + } + + /// One run, as launchd starts it, or with another PATH. Returns its + /// exit status once it has exited and been reaped. What it prints goes + /// to `lastStdout` and `lastStderr`. A run that cannot be started + /// throws, and so does one a signal ended, which has no exit status: + /// its error carries the signal and what the run printed on standard + /// error. The fakes call every tool by its full path. + func run(path: String = "/usr/bin:/bin:/usr/sbin:/sbin") async throws -> Int32 { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/bash") + p.arguments = [script.path] + p.environment = ["PATH": path, "INSOMNIA_HOME": home.path, "HOME": home.path] + let out = dir.appendingPathComponent("last.stdout") + let err = dir.appendingPathComponent("last.stderr") + for url in [out, err] { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw RunError(description: "could not create \(url.path) for the run's output") + } + } + let outHandle = try FileHandle(forWritingTo: out) + defer { try? outHandle.close() } + let errHandle = try FileHandle(forWritingTo: err) + defer { try? errHandle.close() } + p.standardOutput = outHandle + p.standardError = errHandle + let exit = ProcessExit(p) + try p.run() + await exit.exited() + guard p.terminationReason == .exit else { + throw RunError(description: "backstop.sh ended on signal \(p.terminationStatus); stderr: \(lastStderr)") + } + return p.terminationStatus + } + + /// Runs each agent once, at most `width` at a time, and returns their + /// exit statuses in order. The agents must act on separate homes. The + /// runs whose copy has a shortened read limit (`setCommandTimeout`), + /// for a binary that does not answer, start first: each waits that + /// limit out, and one started last would hold the table up alone. + /// Every run that started is waited for, also when another fails to + /// start: the group waits for its tasks before it throws. + static func runAll(_ agents: [PatchedBackstop], width: Int = 16) async throws -> [Int32] { + var statuses = [Int32](repeating: -1, count: agents.count) + let shortened = try agents.map { try $0.readLimitIsShortened() } + let order = agents.indices.filter { shortened[$0] } + agents.indices.filter { !shortened[$0] } + try await withThrowingTaskGroup(of: (Int, Int32).self) { group in + var next = 0 + func add() { + guard next < order.count else { return } + let (i, agent) = (order[next], agents[order[next]]) + group.addTask { (i, try await agent.run()) } + next += 1 + } + for _ in 0..). In a folder + /// that really takes no new file the agent's status files fail too; + /// RecoveryScriptTests runs that case. + func refuseRecordsAside() throws { + try patch("MKTEMP=/usr/bin/mktemp", "MKTEMP=/usr/bin/false") + } + + /// Sets LOCK_RECORD_MAX_BYTES to 0, so no record fits in the recovery + /// lock file and the agent writes none there: a stand-in for a lock + /// file that refuses the write (a full disk). Content already in the + /// file then reads as more than any record, which ends no session. + func refuseLockRecord() throws { + try patch("LOCK_RECORD_MAX_BYTES=1048576", "LOCK_RECORD_MAX_BYTES=0") + } + + /// Points CAT at a fake that fails on the recovery lock file and runs + /// /bin/cat on anything else, so the agent cannot read the lock file + /// back after it writes a record there, and reads session.json and its + /// other files as usual. With `times`, only the first that many reads + /// of the lock file fail, as a passing error would (`lockReads()` + /// counts them). + func failLockReadBack(times: Int? = nil) throws { + let cat = dir.appendingPathComponent("cat") + let reads = dir.appendingPathComponent("lock-reads") + try #""" + #!/bin/bash + if [[ "${1:-}" == */.recovery.lock ]]; then + n=$(( $(/bin/cat '\#(reads.path)' 2>/dev/null || echo 0) + 1 )) + echo "$n" > '\#(reads.path)' + (( n > \#(times ?? Int.max) )) || exit 1 + fi + exec /bin/cat "$@" + + """#.write(to: cat, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: cat.path) + try patch("CAT=/bin/cat", "CAT='\(cat.path)'") + } + + /// How many times the agent read the recovery lock file through the + /// fake of `failLockReadBack`: 0 before the first read, which makes + /// the count file. + func lockReads() throws -> Int { + let url = dir.appendingPathComponent("lock-reads") + guard FileManager.default.fileExists(atPath: url.path) else { return 0 } + let text = try String(contentsOf: url, encoding: .utf8) + guard let count = Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw RunError(description: "lock-reads holds \(text.debugDescription), not a count") + } + return count + } + + /// Points RM at a fake that sets the user append-only flag (chflags + /// uappnd) on `lock` when the agent removes `session`, then runs + /// /bin/rm. The run opened its fd 9 on the lock file before, so from + /// then on the file takes appends (`>>`) and no write that empties it + /// (`>`). The test clears the flag. + func makeLockAppendOnly(whenRemoving session: URL, lock: URL) throws { + let rm = dir.appendingPathComponent("rm") + try #""" + #!/bin/bash + [[ "${1:-}" == -f && "${2:-}" == '\#(session.path)' ]] && /usr/bin/chflags uappnd '\#(lock.path)' + exec /bin/rm "$@" + + """#.write(to: rm, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: rm.path) + try patch("RM=/bin/rm", "RM='\(rm.path)'") + } + + /// Sets LOG_RECORD_MAX_BYTES to 0, so no session.json fits in an end + /// record in insomnia.log: the agent writes none there and reads none + /// back, a stand-in for a log that refuses the line (a full disk). + func refuseLogRecord() throws { + try patch("LOG_RECORD_MAX_BYTES=65536", "LOG_RECORD_MAX_BYTES=0") + } + + /// Points GREP at a fake that finds no end record line in a log (exit + /// 1 for `-Fxq -e insomnia-ended-session-v1 ...`) and runs /usr/bin/grep + /// on anything else: the agent's line goes into insomnia.log, but + /// neither the read-back nor a later run finds it. + func failLogReadBack() throws { + let grep = dir.appendingPathComponent("grep") + try #""" + #!/bin/bash + [[ "${1:-}" == -Fxq && "${2:-}" == -e && "${3:-}" == "insomnia-ended-session-v1 "* ]] && exit 1 + exec /usr/bin/grep "$@" + + """#.write(to: grep, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: grep.path) + try patch("GREP=/usr/bin/grep", "GREP='\(grep.path)'") + } + + /// Points GREP at a fake that, each time the agent looks for an end + /// record line, first appends `cut sh` without a newline to `log` + /// unless the log already ends mid-line, then runs /usr/bin/grep: a + /// line some other write left cut short sits at the end of the log + /// whenever the agent writes there after a check, its record included. + func cutTheLogShortAtEveryRecordCheck(log: URL) throws { + let grep = dir.appendingPathComponent("grep") + try #""" + #!/bin/bash + if [[ "${1:-}" == -Fxq && "${2:-}" == -e && "${3:-}" == "insomnia-ended-session-v1 "* ]]; then + if [[ ! -s '\#(log.path)' || "$(/usr/bin/tail -c 1 '\#(log.path)'; printf x)" == $'\nx' ]]; then + printf 'cut sh' >> '\#(log.path)' + fi + fi + exec /usr/bin/grep "$@" + + """#.write(to: grep, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: grep.path) + try patch("GREP=/usr/bin/grep", "GREP='\(grep.path)'") + } + + /// Points GREP at a fake that, each time the agent looks for an end + /// record line while no other writer holds insomnia.log's lock (lockf + /// -t 0 gets it), first asks another writer to take that lock, write + /// `held sh`, wait a second, then write `ort`, still without a newline, + /// and let go. The writer is a thread in the test process (LogWriters) + /// that takes the lock with flock, as lockf does. The fake runs + /// /usr/bin/grep once that writer says it holds the lock, or after 5 s + /// without it. So the agent's write after such a check, its record + /// included, waits for that writer and finds the log ends in a line cut + /// short. A check while the lock is held (the agent's own read-back) + /// asks for no writer. Settle the writers before the home goes. + func holdTheLogAtEveryRecordCheck(log: URL) throws -> LogWriters { + let grep = dir.appendingPathComponent("grep") + let writers = try LogWriters(dir: dir.appendingPathComponent("log-writers", isDirectory: true), log: log) + let requests = writers.requests.path + let acks = writers.acks.path + try #""" + #!/bin/bash + # A request is a file in the requests folder, made under a dot name + # and renamed, so the writer sees it whole. The writer makes the file + # of the same name in the acks folder once it holds the lock. The fake + # records whether that came in time in the seen or late folder. + if [[ "${1:-}" == -Fxq && "${2:-}" == -e && "${3:-}" == "insomnia-ended-session-v1 "* && -f '\#(log.path)' ]] \ + && /usr/bin/lockf -k -s -t 0 '\#(log.path)' /usr/bin/true; then + if made=$(/usr/bin/mktemp '\#(requests)/.XXXXXX') && id=${made##*/.} && /bin/mv "$made" '\#(requests)/'"$id"; then + answered="" + for _ in $(/usr/bin/jot 500); do + if [[ -e '\#(acks)/'"$id" ]]; then answered=1; break; fi + /bin/sleep 0.01 + done + if [[ -n "$answered" ]]; then : > '\#(writers.seen.path)/'"$id"; else : > '\#(writers.late.path)/'"$id"; fi + else + printf 'the fake grep could not make a request\n' >> '\#(writers.failures.path)' + fi + fi + exec /usr/bin/grep "$@" + + """#.write(to: grep, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: grep.path) + try patch("GREP=/usr/bin/grep", "GREP='\(grep.path)'") + return writers + } + + /// The writer holdTheLogAtEveryRecordCheck's fake asks for: one thread + /// in the test process, so no process outlives the test and none needs + /// reaping. For each request, in the order it finds them, it opens the + /// log, takes its lock with flock (waiting up to 5 s), writes `held sh`, + /// acknowledges the request, waits a second, writes `ort` and closes the + /// log, which lets the lock go. Each write and file operation is + /// checked. `settle` stops the thread and checks that it served every + /// request and that every fake saw its acknowledgment in time. + final class LogWriters: @unchecked Sendable { + /// The thread did not finish in time and may still write to the log. + struct Unsettled: Error, CustomStringConvertible { + let description: String + } + + let requests: URL + let acks: URL + let seen: URL + let late: URL + /// What the fake could not do, one line each. + let failures: URL + private let log: URL + private let lock = NSLock() + private let finished = DispatchSemaphore(value: 0) + private var stopping = false + /// Each request served, in order, with the writer's status: 0, 75 + /// when the lock stayed taken for 5 s, 74 when a write or file + /// operation failed. + private var served: [(id: String, status: Int32)] = [] + private var problems: [String] = [] + private var settling: Task<[Int32], Error>? + + init(dir: URL, log: URL) throws { + self.log = log + requests = dir.appendingPathComponent("requests", isDirectory: true) + acks = dir.appendingPathComponent("acks", isDirectory: true) + seen = dir.appendingPathComponent("seen", isDirectory: true) + late = dir.appendingPathComponent("late", isDirectory: true) + failures = dir.appendingPathComponent("failures") + for folder in [requests, acks, seen, late] { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + let thread = Thread { [self] in serve() } + thread.name = "PatchedBackstop.LogWriters" + thread.start() + } + + private func note(_ problem: String) { lock.withLock { problems.append(problem) } } + + /// Serves requests every 10 ms until asked to stop. A scan that + /// starts after the stop request and finds nothing new ends it, so a + /// request made before `settle` is served. + private func serve() { + defer { finished.signal() } + var done = Set() + while true { + let stop = lock.withLock { stopping } + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory(atPath: requests.path).filter { !$0.hasPrefix(".") }.sorted() + } catch { + note("could not list \(requests.path): \(error)") + return + } + let new = names.filter { !done.contains($0) } + for id in new { + let status = serveRequest(id) + done.insert(id) + lock.withLock { served.append((id, status)) } + } + if stop && new.isEmpty { return } + Thread.sleep(forTimeInterval: 0.01) + } + } + + private func serveRequest(_ id: String) -> Int32 { + let fd = open(log.path, O_WRONLY | O_APPEND | O_CLOEXEC) + guard fd >= 0 else { + note("request \(id): could not open \(log.path): errno \(errno)") + return 74 + } + var status = hold(fd, for: id) + // Closing the log lets its lock go. + if close(fd) != 0 { + note("request \(id): closing the log: errno \(errno)") + status = 74 + } + return status + } + + private func hold(_ fd: Int32, for id: String) -> Int32 { + let deadline = Date().addingTimeInterval(5) + while flock(fd, LOCK_EX | LOCK_NB) != 0 { + guard errno == EWOULDBLOCK || errno == EINTR else { + note("request \(id): flock: errno \(errno)") + return 74 + } + if Date() >= deadline { return 75 } + Thread.sleep(forTimeInterval: 0.01) + } + guard append("held sh", to: fd, for: id) else { return 74 } + let ack = acks.appendingPathComponent(id).path + let made = open(ack, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) + guard made >= 0 else { + note("request \(id): could not make \(ack): errno \(errno)") + return 74 + } + guard close(made) == 0 else { + note("request \(id): closing \(ack): errno \(errno)") + return 74 + } + Thread.sleep(forTimeInterval: 1) + return append("ort", to: fd, for: id) ? 0 : 74 + } + + private func append(_ text: String, to fd: Int32, for id: String) -> Bool { + var bytes = Array(text.utf8)[...] + while !bytes.isEmpty { + let n = bytes.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + if n > 0 { + bytes = bytes.dropFirst(n) + } else if n < 0 && errno == EINTR { + continue + } else { + note("request \(id): writing \(text.debugDescription) to the log: \(n), errno \(errno)") + return false + } + } + return true + } + + /// Stops the writer and returns each request's status in the order + /// it was served, once the thread has finished. Throws `Unsettled` + /// when it has not finished within 30 s of the stop request, which + /// is far longer than a request takes (at most 5 s for the lock and + /// one second held). Throws when a write or file operation failed, + /// a request was not served, or a fake did not see its + /// acknowledgment in time. Every call, also one made while another + /// is waiting, gets the outcome of the first. + @discardableResult + func settle() async throws -> [Int32] { + let task = lock.withLock { () -> Task<[Int32], Error> in + if let settling { return settling } + stopping = true + let task = Task.detached { [self] in try await join() } + settling = task + return task + } + return try await task.value + } + + private func join() async throws -> [Int32] { + let finished = self.finished + let joined = await withCheckedContinuation { (done: CheckedContinuation) in + DispatchQueue.global().async { done.resume(returning: finished.wait(timeout: .now() + 30) == .success) } + } + guard joined else { + throw Unsettled(description: "the log writer did not finish within 30 s of the stop request and may still write to \(log.path)") + } + let (served, problems) = lock.withLock { (self.served, self.problems) } + func names(_ folder: URL) throws -> Set { + Set(try FileManager.default.contentsOfDirectory(atPath: folder.path).filter { !$0.hasPrefix(".") }) + } + let requested = try names(requests) + let seenNames = try names(seen) + let lateNames = try names(late) + var failed = "" + if FileManager.default.fileExists(atPath: failures.path) { + failed = try String(contentsOf: failures, encoding: .utf8) + } + let ids = served.map(\.id) + guard problems.isEmpty, failed.isEmpty, lateNames.isEmpty, Set(ids) == requested, ids.count == requested.count, seenNames == requested else { + throw RunError(description: "the log writer served \(served) of requests \(requested.sorted()); acknowledged in time \(seenNames.sorted()), late \(lateNames.sorted()); problems \(problems); the fake's failures \(failed.debugDescription)") + } + return served.map(\.status) + } + } + + private func patch(_ line: String, _ replacement: String) throws { + let text = try String(contentsOf: script, encoding: .utf8) + let hits = text.components(separatedBy: "\n").filter { $0 == line }.count + guard hits == 1 else { throw PatchError(constant: String(line.prefix { $0 != "=" }), hits: hits) } + try text.replacingOccurrences(of: "\n\(line)\n", with: "\n\(replacement)\n").write(to: script, atomically: true, encoding: .utf8) + } + + /// The call that restores sleep. + var restoreCall: String { "sudo -n \(dir.appendingPathComponent("pmset").path) -a disablesleep 0" } + + /// state.json as the last sudo call found it, or nil when there was none. + var stateAtSudo: Data? { read("state-at-sudo") } + + /// The names in INSOMNIA_HOME as the last sudo call found them. + var namesAtSudo: [String] { + String(decoding: read("names-at-sudo") ?? Data(), as: UTF8.self).split(separator: "\n").map(String.init) + } + + /// The names in INSOMNIA_HOME/Logs as the last sudo call found them. + var logsAtSudo: [String] { + String(decoding: read("logs-at-sudo") ?? Data(), as: UTF8.self).split(separator: "\n").map(String.init) + } + + /// Makes the app binary unusable for the agent: its Info.plist then + /// declares no `--agent-cutoffs` version, so the binary is not run. + func withdrawAgentCutoffs() throws { + try BuiltApp.infoPlist(agentCutoffsVersion: nil).write(to: dir.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + } + + /// The app binary the agent runs (INSOMNIA_BIN). + var appBinary: URL { dir.appendingPathComponent("insomnia_bin") } + + /// Replaces the app binary with a script running `body`. + func replaceAppBinary(with body: String) throws { + try "#!/bin/bash\n\(body)\n".write(to: appBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: appBinary.path) + } + + /// Sets LOG_LOCK_TIMEOUT_SECONDS, the longest each of the agent's + /// writes to insomnia.log waits for the log's lock, in this copy. + func setLogLockTimeout(_ seconds: Int) throws { + try patch("LOG_LOCK_TIMEOUT_SECONDS=5", "LOG_LOCK_TIMEOUT_SECONDS=\(seconds)") + } + + /// Sets COMMAND_TIMEOUT_SECONDS, the limit on each read, in this copy. + func setCommandTimeout(_ seconds: Int) throws { + let text = try String(contentsOf: script, encoding: .utf8) + let line = "COMMAND_TIMEOUT_SECONDS=30" + guard text.components(separatedBy: "\n").filter({ $0 == line }).count == 1 else { throw PatchError(constant: "COMMAND_TIMEOUT_SECONDS", hits: 0) } + try text.replacingOccurrences(of: "\n\(line)\n", with: "\nCOMMAND_TIMEOUT_SECONDS=\(seconds)\n").write(to: script, atomically: true, encoding: .utf8) + } + + /// Whether setCommandTimeout has changed this copy's limit on each read + /// from the 30 s backstop.sh sets. + func readLimitIsShortened() throws -> Bool { + let text = try String(contentsOf: script, encoding: .utf8) + return !text.components(separatedBy: "\n").contains("COMMAND_TIMEOUT_SECONDS=30") + } +} diff --git a/Tests/InsomniaTests/PathSubstitutionTests.swift b/Tests/InsomniaTests/PathSubstitutionTests.swift new file mode 100644 index 00000000..37a37de5 --- /dev/null +++ b/Tests/InsomniaTests/PathSubstitutionTests.swift @@ -0,0 +1,112 @@ +import Foundation +import XCTest +@testable import Insomnia + +/// A PATH whose cat, grep, head, tail, tr, iconv, id, stat, awk, basename +/// and dirname are stand-ins, for the tests that show the recovery scripts +/// take every tool that reads state from its fixed path (CAT, GREP, HEAD, +/// TAIL, TR, ICONV, ID, STAT, AWK), never from PATH, and that neither +/// script names a folder with basename or dirname (uninstall.sh finds its +/// own folder by parameter expansion; install.sh, which still runs +/// dirname, is not run here). iconv runs only on a journal in UTF-16, and +/// grep in the journal's reader only on one whose strings hold \u0000, +/// which these tests do not write. A stand-in called by a process whose +/// command line holds one of `scripts` (a recovery script, or a subshell of +/// one) logs the call and answers with something that would change the +/// run: a 0% end floor from the app's binary, a full battery, a file that +/// is not JSON, uid 0. Called by anything else (a test's fake sudo), it +/// runs the real tool. +enum PathSubstitutes { + static let tools: [(name: String, real: String, answer: String)] = [ + ("cat", "/bin/cat", "printf 'cutoffs 0 false\\n'"), + ("grep", "/usr/bin/grep", #"printf ' -InternalBattery-0 (id=1)\t100%%; charged; 0:00 remaining present: true\n'"#), + ("head", "/usr/bin/head", "printf x"), + ("tail", "/usr/bin/tail", "printf x"), + ("tr", "/usr/bin/tr", ":"), + ("iconv", "/usr/bin/iconv", "printf '{}'"), + ("id", "/usr/bin/id", "echo 0"), + ("stat", "/usr/bin/stat", "echo 0"), + ("awk", "/usr/bin/awk", ":"), + ("basename", "/usr/bin/basename", "echo Insomnia.app"), + ("dirname", "/usr/bin/dirname", "echo /"), + ] + + /// Writes the stand-ins in `dir` and returns the PATH that puts them + /// first. Each call a script makes is a line in `log`. + static func path(in dir: URL, log: URL, scripts: [String]) throws -> String { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let callers = scripts.map { "*'\($0)'*" }.joined(separator: "|") + for tool in tools { + let url = dir.appendingPathComponent(tool.name) + try """ + #!/bin/bash + case "$(/bin/ps -o command= -p "$PPID" 2>/dev/null)" in + \(callers)) + printf '%s %s\\n' \(tool.name) "$*" >> '\(log.path)' + \(tool.answer) + exit 0 ;; + esac + exec \(tool.real) "$@" + + """.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + return "\(dir.path):/usr/bin:/bin:/usr/sbin:/sbin" + } + + /// The calls the scripts made to a stand-in. + static func calls(in log: URL) -> [String] { + ((try? String(contentsOf: log, encoding: .utf8)) ?? "").split(separator: "\n").map(String.init) + } +} + +/// Greptile 4219151895, backstop.sh's side (RecoveryScriptTests runs +/// uninstall.sh's): the agent, run on twin homes with the usual PATH (the +/// control) and with PathSubstitutes first in PATH, ends the same session +/// the same way, and calls no stand-in. +@MainActor +final class PathSubstitutionTests: XCTestCase { + var h: Harness! + + override func setUp() async throws { + h = Harness() + try h.home.paths.createDirectories() + } + + override func tearDown() async throws { + h.home.destroy() + } + + /// A session at 25% on battery power with a 30% end floor, the app + /// alive: the agent reads session.json, the cutoffs through the app's + /// binary (whose answer it reads back from a file), the battery from + /// pmset and the journal, then ends the session. A script that took cat, + /// grep or head from PATH here would read a 0% floor or a full battery + /// and keep the session, or find session.json or the journal not JSON. + func testTheAgentTakesNoToolFromPath() async throws { + let config = Data(#"{"endFloor":30,"thermalRules":false,"configVersion":2,"lidCloseDefaultsApplied":true}"#.utf8) + let control = try SeparateRun(in: h, name: "control", config: config, battery: 25) + let twin = try SeparateRun(in: h, name: "twin", config: config, battery: 25) + let log = h.home.root.appendingPathComponent("substitutes.log") + let path = try PathSubstitutes.path(in: h.home.root.appendingPathComponent("substitutes", isDirectory: true), log: log, + scripts: [twin.agent.script.path]) + + let controlStatus = try await control.agent.run() + let status = try await twin.agent.run(path: path) + control.alive.release() + twin.alive.release() + + let (controlLog, twinLog) = (try control.log, try twin.log) + XCTAssertEqual(PathSubstitutes.calls(in: log), [], "the agent called a tool from PATH") + XCTAssertEqual(controlStatus, 0, controlLog) + XCTAssertEqual(status, 0, twinLog) + XCTAssertNil(try Store(paths: control.paths).loadSession(), controlLog) + XCTAssertNil(try Store(paths: twin.paths).loadSession(), twinLog) + XCTAssertTrue(controlLog.contains("below the 30% end floor"), controlLog) + XCTAssertTrue(twinLog.contains("below the 30% end floor"), twinLog) + let calls = { (run: SeparateRun) in run.agent.calls.map { $0.replacingOccurrences(of: run.agent.dir.path, with: "") } } + XCTAssertEqual(calls(twin), calls(control)) + XCTAssertTrue(control.agent.calls.contains(control.agent.restoreCall), control.agent.calls.joined(separator: "\n")) + XCTAssertEqual(try Store(paths: twin.paths).loadState(), try Store(paths: control.paths).loadState()) + } +} diff --git a/Tests/InsomniaTests/PrivilegedCommandLockTests.swift b/Tests/InsomniaTests/PrivilegedCommandLockTests.swift index ff71044f..b81a5988 100644 --- a/Tests/InsomniaTests/PrivilegedCommandLockTests.swift +++ b/Tests/InsomniaTests/PrivilegedCommandLockTests.swift @@ -12,7 +12,7 @@ final class PrivilegedCommandLockTests: XCTestCase { private var dir: URL! override func setUpWithError() throws { - dir = FileManager.default.temporaryDirectory + dir = ProcessTestHome.temporaryDirectory .appendingPathComponent("insomnia-held-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } diff --git a/Tests/InsomniaTests/ReconcileLidGatingTests.swift b/Tests/InsomniaTests/ReconcileLidGatingTests.swift index e3a2cfc1..ea95b7e5 100644 --- a/Tests/InsomniaTests/ReconcileLidGatingTests.swift +++ b/Tests/InsomniaTests/ReconcileLidGatingTests.swift @@ -24,6 +24,7 @@ final class ReconcileLidGatingTests: XCTestCase { st.savedKeyboardBrightness = 0.3 try h.store.saveState(st) h.procs.stoppedNow = [111, 222] + h.guardFake.sleepDisabled = true // the crashed session's hold return s } @@ -47,7 +48,7 @@ final class ReconcileLidGatingTests: XCTestCase { XCTAssertEqual(after.savedDisplayBrightness, 0.8) XCTAssertEqual(after.savedKeyboardBrightness, 0.3) XCTAssertEqual(m.state, after) - XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"]) + XCTAssertEqual(h.guardFake.calls, ["pmset -g", "disablesleep 1"]) XCTAssertFalse(m.countdownTimerArmed, "the session resumed under a closed lid redraws every second") } @@ -62,6 +63,7 @@ final class ReconcileLidGatingTests: XCTestCase { st.savedDisplayBrightness = 0.8 st.savedKeyboardBrightness = 0.3 try h.store.saveState(st) + h.guardFake.sleepDisabled = true // the crashed session's hold h.clamshell.closed = true let m = h.makeManager() await m.reconcile() diff --git a/Tests/InsomniaTests/ReconcileTests.swift b/Tests/InsomniaTests/ReconcileTests.swift index 3738de7e..3c199715 100644 --- a/Tests/InsomniaTests/ReconcileTests.swift +++ b/Tests/InsomniaTests/ReconcileTests.swift @@ -59,7 +59,7 @@ final class ReconcileTests: XCTestCase { XCTAssertEqual(m.session, s) XCTAssertTrue(m.isActive) - XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"]) + XCTAssertEqual(h.guardFake.calls, ["pmset -g", "disablesleep 1"], "the journaled hold is confirmed first") XCTAssertEqual(m.scheduledDeadline, s.endsAt) XCTAssertEqual(h.backstop.arms, 1) XCTAssertEqual(m.remainingText, "2h 14m") @@ -77,6 +77,50 @@ final class ReconcileTests: XCTestCase { XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"]) } + // (b'') valid session whose journaled hold no longer holds: pmset reads + // SleepDisabled 0, so the disable was undone while Insomnia was not + // running (here by hand; in JournaledSessionEndTests by an agent end + // that could record nothing). Ended from the journal, not held again. + func testAValidSessionWhoseJournaledHoldWasUndoneIsEndedNotResumed() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3600))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = false + + let m = h.makeManager() + await m.reconcile() + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertFalse(h.guardFake.sleepDisabled) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState()?.isDirty, false) + XCTAssertEqual(h.notifier.posts.last?.title, "Sleep restored") + } + + // (b''') the same session when pmset -g cannot be read: the hold + // cannot be confirmed, so the session ends instead of being held again. + func testAValidSessionWhoseJournaledHoldCannotBeConfirmedIsEndedNotResumed() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3600))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true + h.guardFake.throwOn = ["pmset -g"] + + let m = h.makeManager() + await m.reconcile() + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState()?.isDirty, false) + } + // (c) no session, no journal entry, but pmset reports SleepDisabled: // something else set it. Left alone, reported once with the command to // undo it; an Insomnia session's end still sets it to 0 as always. @@ -379,6 +423,234 @@ final class ReconcileTests: XCTestCase { XCTAssertEqual(m.remainingText, "58m") } + // MARK: A session the recovery agent ended while the app could not act + + /// backstop.sh ended the session (battery below the floor while the app + /// was stopped, say) and restored from the journal: session.json gone, + /// state.json clean. The app still holds the session in memory. Its next + /// transaction (an extend here) ends it on the app's side from the clean + /// journal: no pmset, no session written back, countdown stopped, and a + /// notification that says who ended it. A later end by the user is then + /// an ordinary end with nothing left to do. + func testASessionTheAgentEndedIsDroppedAtTheNextTransaction() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"]) + try h.store.deleteSession() + try h.store.saveState(.clean) + + await m.extend(by: 600) + + XCTAssertNil(m.session) + XCTAssertFalse(m.isActive) + XCTAssertNil(try h.store.loadSession(), "the extend must not write the session back") + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"], "the agent restored sleep; nothing to undo here") + XCTAssertFalse(m.countdownTimerArmed) + XCTAssertEqual(h.notifier.posts.last?.title, "Session ended") + XCTAssertTrue(h.notifier.posts.last?.body.contains("recovery agent ended the session") ?? false, "\(h.notifier.posts)") + + let outcome = await m.end(reason: .user) + XCTAssertEqual(outcome, .restored) + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"]) + } + + /// What the agent could not undo stays in its journal, and the app's end + /// retries it from there: the agent restored sleep but left a frozen + /// process, which the app resumes. + func testTheAppRetriesWhatTheAgentLeftJournaled() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + try h.store.deleteSession() + var left = RuntimeState.clean + left.frozenProcesses = [FrozenProcess(pid: 111, startedAt: 5)] + try h.store.saveState(left) + + await m.extend(by: 600) + + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.procs.resumed, [[111]]) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + } + + /// An end requested by the user does the same work itself and must not + /// be doubled by the check: one end, one notification. + func testAUserEndAfterTheAgentsEndIsOneEnd() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + try h.store.deleteSession() + try h.store.saveState(.clean) + let before = h.notifier.posts.count + + let outcome = await m.end(reason: .user) + + XCTAssertEqual(outcome, .restored) + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.notifier.posts.count, before + 1) + XCTAssertEqual(h.notifier.posts.last?.body, "Ended by you. Sleep is back to normal.") + } + + /// With the lid open the countdown ticks once a second, and a tick that + /// finds session.json gone ends the session within about a second, with + /// no transaction of the user's needed. + func testTheCountdownTickNoticesASessionTheAgentEnded() async throws { + // Real-time harness so the 1 Hz Timer actually fires. + let real = Harness(now: Date()) + defer { real.home.destroy() } + let m = real.makeManager() + await m.start(duration: 3600) + XCTAssertTrue(m.isActive) + XCTAssertTrue(m.countdownTimerArmed) + try real.store.deleteSession() + try real.store.saveState(.clean) + + let deadline = Date().addingTimeInterval(8) + while m.isActive && Date() < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + + XCTAssertFalse(m.isActive) + XCTAssertFalse(m.countdownTimerArmed) + XCTAssertEqual(real.guardFake.calls, ["disablesleep 1"]) + XCTAssertTrue(real.notifier.posts.last?.body.contains("recovery agent") ?? false, "\(real.notifier.posts)") + } + + /// backstop.sh removes session.json before its undo, so a pmset of its + /// that hangs still holds the recovery lock when the tick sees the end. + /// The test runs the ticks itself: the 1 Hz timer's first fire date + /// comes from the harness clock, which is fixed in 2027, so it never + /// fires during the test. The first tick finds the lock held and + /// fails one bounded wait. Ticks within the retry delay do not try + /// again, although the lock is free by then. The first tick after the + /// delay ends the session. + func testTheTickWaitsTheRetryDelayWhileTheAgentHoldsTheLock() async throws { + let m = h.makeManager(retryDelay: 60) + await m.start(duration: 3600) + let held = try XCTUnwrap(try RecoveryLock(url: h.home.paths.recoveryLock).tryAcquire()) + try h.store.deleteSession() + try h.store.saveState(.clean) + func skipped() -> Int { + let log = (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + return log.components(separatedBy: "agent end skipped").count - 1 + } + + await m.noticeAgentEnd() + XCTAssertEqual(skipped(), 1) + XCTAssertTrue(m.isActive) + + held.release() + await m.noticeAgentEnd() + h.clock.advance(59) + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive, "no new attempt before the retry delay") + XCTAssertEqual(skipped(), 1) + + h.clock.advance(1) + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertEqual(skipped(), 1) + XCTAssertTrue(h.notifier.posts.last?.body.contains("recovery agent") ?? false, "\(h.notifier.posts)") + } + + /// The same backoff for a journal that does not decode: the first tick + /// that sees the agent's end is refused, and later ticks inside the + /// retry delay start no transaction and log nothing. Once the journal + /// is repaired, the first tick after the delay ends the session. + func testTheTickWaitsTheRetryDelayWhileTheJournalIsUnreadable() async throws { + let m = h.makeManager(retryDelay: 60) + await m.start(duration: 3600) + try h.store.deleteSession() + try Data("{unreadable journal".utf8).write(to: h.home.paths.stateFile) + let calls = h.guardFake.calls + func refused() -> Int { + let log = (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + return log.components(separatedBy: "agent end refused, nothing changed:").count - 1 + } + + await m.noticeAgentEnd() + await m.noticeAgentEnd() + h.clock.advance(59) + await m.noticeAgentEnd() + XCTAssertEqual(refused(), 1, "one attempt inside the retry delay") + XCTAssertTrue(m.isActive) + XCTAssertEqual(h.guardFake.calls, calls, "nothing changed") + + try h.store.saveState(.clean) + await m.noticeAgentEnd() + XCTAssertTrue(m.isActive, "repaired, but still inside the delay") + + h.clock.advance(1) + await m.noticeAgentEnd() + XCTAssertFalse(m.isActive) + XCTAssertEqual(refused(), 1) + XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + XCTAssertTrue(h.notifier.posts.last?.body.contains("recovery agent") ?? false, "\(h.notifier.posts)") + } + + // MARK: A session.json recorded as ended + + /// The agent ended the session but could not remove session.json, so it + /// recorded the end in ended-session.json. The tick treats the record as + /// it treats a missing file and ends the session here. + func testTheTickAdoptsASessionTheAgentRecordedAsEnded() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + XCTAssertTrue(h.store.recordSessionEnd()) + try h.store.saveState(.clean) + + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1"], "the agent restored sleep") + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path), "the record goes with the file") + XCTAssertTrue(h.notifier.posts.last?.body.contains("recovery agent ended the session") ?? false, "\(h.notifier.posts)") + } + + /// At launch, a valid session.json whose end is recorded is restored as + /// an ended session, never resumed, and both files are removed. + func testASessionRecordedAsEndedIsRestoredNotResumed() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3600))) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true + XCTAssertTrue(h.store.recordSessionEnd()) + + let m = h.makeManager() + await m.reconcile() + + XCTAssertFalse(m.isActive) + XCTAssertFalse(h.guardFake.calls.contains("disablesleep 1"), "\(h.guardFake.calls)") + XCTAssertTrue(h.guardFake.calls.contains("disablesleep 0"), "\(h.guardFake.calls)") + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path)) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + } + + /// A record of an earlier session.json matches no later one: the session + /// on disk now is resumed as usual. + func testAStaleEndRecordDoesNotEndTheSessionOnDisk() async throws { + let now = h.clock.now + try h.store.saveSession(Session(startedAt: now.addingTimeInterval(-7200), endsAt: now.addingTimeInterval(1800))) + XCTAssertTrue(h.store.recordSessionEnd()) + let s = Session(startedAt: now.addingTimeInterval(-600), endsAt: now.addingTimeInterval(3600)) + try h.store.saveSession(s) + var st = RuntimeState() + st.sleepDisabledByUs = true + try h.store.saveState(st) + h.guardFake.sleepDisabled = true // the crashed session's hold + + let m = h.makeManager() + await m.reconcile() + + XCTAssertEqual(m.session, s) + XCTAssertEqual(h.guardFake.calls, ["pmset -g", "disablesleep 1"]) + } + func testDeadlineTimerFiresEnd() async throws { // Use the real clock for this one so the Timer can actually fire. let real = Harness(now: Date()) diff --git a/Tests/InsomniaTests/RecoveryLockTests.swift b/Tests/InsomniaTests/RecoveryLockTests.swift index e311715b..2dddfdb6 100644 --- a/Tests/InsomniaTests/RecoveryLockTests.swift +++ b/Tests/InsomniaTests/RecoveryLockTests.swift @@ -7,13 +7,24 @@ import XCTest final class RecoveryLockTests: XCTestCase { var home: TempHome! var lock: RecoveryLock! + /// The lockf holder a test started, if any. + var holder: LockfHolder? override func setUp() { home = TempHome() lock = RecoveryLock(url: home.paths.recoveryLock) } - override func tearDown() { home.destroy() } + /// Stops a holder the test left running. One that still has not + /// exited keeps the home it locks. + override func tearDown() { + if let holder, !holder.isSettled, holder.stop() == -1 { + XCTFail("the lockf holder \(holder.child.pid) did not exit; keeping \(home.root.path): \(holder.problems)") + home.keep() + return + } + home.destroy() + } func testSecondHolderIsRefusedUntilRelease() throws { let first = try XCTUnwrap(try lock.tryAcquire()) @@ -57,24 +68,18 @@ final class RecoveryLockTests: XCTestCase { /// backstop.sh locks with `/usr/bin/lockf -k` on the same path. Prove the /// two really contend: a lockf holder in another process blocks the app. - func testLockHeldByLockfInAnotherProcessIsRespected() async throws { - let ready = home.root.appendingPathComponent("holder-ready") - let holder = Process() - holder.executableURL = URL(fileURLWithPath: "/usr/bin/lockf") - holder.arguments = ["-k", "-t", "0", home.paths.recoveryLock.path, "/bin/sh", "-c", "touch '\(ready.path)'; sleep 30"] - let holderExit = ProcessExit(holder) - try holder.run() - defer { holder.terminate() } - let deadline = Date().addingTimeInterval(5) - while !FileManager.default.fileExists(atPath: ready.path), Date() < deadline { - try await Task.sleep(for: .milliseconds(20)) - } - XCTAssertTrue(FileManager.default.fileExists(atPath: ready.path), "lockf holder never started") + /// The holder's command is cat, which prints back a line only once lockf + /// holds the lock and runs it. Closing cat's input ends cat, lockf reaps + /// it and exits 0, and the test reaps lockf (LockfHolder). + func testLockHeldByLockfInAnotherProcessIsRespected() throws { + let holder = try LockfHolder( + file: home.paths.recoveryLock, wait: 0, errors: home.root.appendingPathComponent("holder-errors"), + environment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"], directory: home.root) + self.holder = holder + XCTAssertTrue(holder.held(within: 5), "lockf holder never ran its command: \(holder.problems) \(holder.errorText())") XCTAssertNil(try lock.tryAcquire(), "flock did not see the lock lockf holds") - holder.terminate() - let exited = await holderExit.exited(within: 10) - XCTAssertTrue(exited, "the lockf holder did not exit within 10 s of SIGTERM") + XCTAssertEqual(holder.stop(), 0, "lockf, reaped once its cat read the end of its input: \(holder.problems) \(holder.errorText())") XCTAssertNotNil(try lock.tryAcquire()) } } diff --git a/Tests/InsomniaTests/RecoverySafetyTests.swift b/Tests/InsomniaTests/RecoverySafetyTests.swift index 37a42252..83ec6e5e 100644 --- a/Tests/InsomniaTests/RecoverySafetyTests.swift +++ b/Tests/InsomniaTests/RecoverySafetyTests.swift @@ -403,7 +403,9 @@ final class RecoverySafetyTests: XCTestCase { XCTAssertEqual(outcome, .sessionRetained) XCTAssertNotNil(try h.store.loadSession(), "fixture did not keep session.json") XCTAssertFalse(h.guardFake.sleepDisabled, "the machine must still be restored") - XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + var kept = RuntimeState.clean + kept.sessionCutoffs = m.config.agentCutoffs + XCTAssertEqual(try h.store.loadState(), kept, "the session's cutoffs stay while its session.json does") XCTAssertEqual(m.pendingEnd, .quit) XCTAssertFalse(m.quitRequested) let last = try XCTUnwrap(h.notifier.posts.last) @@ -418,6 +420,7 @@ final class RecoverySafetyTests: XCTestCase { let second = await m.end(reason: .user) XCTAssertEqual(second, .restored) XCTAssertNil(try h.store.loadSession()) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) XCTAssertNil(m.pendingEnd) // A fresh launch finds nothing to hold. @@ -427,6 +430,59 @@ final class RecoverySafetyTests: XCTestCase { XCTAssertFalse(h.guardFake.calls.dropFirst(2).contains("disablesleep 1"), "\(h.guardFake.calls)") } + /// The end the app could not finish by removing session.json is still + /// durable: it records the end, and the next launch restores instead of + /// holding sleep again, even while the file stays. + func testAnEndThatCannotRemoveSessionJSONRecordsItForTheNextLaunch() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + let file = h.home.paths.sessionFile + try setImmutable(file, true) + defer { try? setImmutable(file, false) } + + let outcome = await m.end(reason: .user) + + XCTAssertEqual(outcome, .sessionRetained) + XCTAssertTrue(h.store.sessionEndIsRecorded()) + let last = try XCTUnwrap(h.notifier.posts.last) + XCTAssertTrue(last.body.contains("its end is recorded, so a relaunch will not resume it"), last.body) + + let relaunched = h.makeManager() + await relaunched.reconcile() + + XCTAssertFalse(relaunched.isActive) + XCTAssertEqual(h.guardFake.calls.filter { $0 == "disablesleep 1" }.count, 1, "\(h.guardFake.calls)") + + // Both pending ends finish once the file can go, which also stops + // their retry timers. + try setImmutable(file, false) + let relaunchedEnd = await relaunched.end(reason: .user) + XCTAssertEqual(relaunchedEnd, .restored) + let firstEnd = await m.end(reason: .user) + XCTAssertEqual(firstEnd, .restored) + XCTAssertNil(try h.store.loadSession()) + XCTAssertFalse(FileManager.default.fileExists(atPath: h.home.paths.endedSessionFile.path)) + } + + /// A record whose session.json is gone but that cannot be removed + /// itself is logged, not dropped silently. The end still completes. + func testAnEndRecordThatCannotBeRemovedIsLogged() async throws { + let m = h.makeManager() + await m.start(duration: 3600) + XCTAssertTrue(h.store.recordSessionEnd()) + let record = h.home.paths.endedSessionFile + try setImmutable(record, true) + defer { try? setImmutable(record, false) } + + let outcome = await m.end(reason: .user) + + XCTAssertEqual(outcome, .restored) + XCTAssertNil(try h.store.loadSession()) + XCTAssertTrue(FileManager.default.fileExists(atPath: record.path)) + let log = (try? String(contentsOf: h.home.paths.logFile, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains("could not remove \(record.path)"), log) + } + // MARK: Cross-process lock: fail closed /// The backstop holds the recovery lock. An end must change nothing: @@ -607,4 +663,77 @@ final class RecoverySafetyTests: XCTestCase { XCTAssertFalse(h.guardFake.sleepDisabled) XCTAssertNil(m.pendingEnd) } + + /// An end refused for an unreadable journal is retried like any other + /// pending end: once the file is repaired, the retry restores with no + /// new request, and the journal notification is not repeated. + func testAnEndRefusedForAnUnreadableJournalIsRetriedOnceTheFileIsRepaired() async throws { + let m = h.makeManager(retryDelay: 0.3) + await m.start(duration: 3600) + try Data(#"{"sleepDisabledByUs": tru"#.utf8).write(to: h.home.paths.stateFile) + + let outcome = await m.end(reason: .timer) + XCTAssertEqual(outcome, .journalUnreadable) + XCTAssertEqual(m.pendingEnd, .timer) + var repaired = RuntimeState() + repaired.sleepDisabledByUs = true + try h.store.saveState(repaired) + + for _ in 0..<1000 where m.pendingEnd != nil { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNil(m.pendingEnd, "the refused end was never retried") + XCTAssertFalse(m.isActive) + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1", "disablesleep 0"]) + XCTAssertEqual(try h.store.loadState(), RuntimeState.clean) + XCTAssertEqual(h.notifier.posts.filter { $0.title == SessionManager.journalTitle }.count, 1, "\(h.notifier.posts)") + } + + /// The deadline end finds the journal unreadable and stays pending. A + /// person repairs the journal, and the agent restores and removes + /// session.json before the retry runs. The tick adopts the agent's end, + /// which settles the pending end and its retry, so the next start is + /// not refused. + func testAnAdoptedAgentEndSettlesAnEndPendingOnAnUnreadableJournal() async throws { + let m = h.makeManager(retryDelay: 3600) + await m.start(duration: 3600) + try Data(#"{"sleepDisabledByUs": tru"#.utf8).write(to: h.home.paths.stateFile) + let outcome = await m.end(reason: .timer) + XCTAssertEqual(outcome, .journalUnreadable) + XCTAssertEqual(m.pendingEnd, .timer) + + try h.store.saveState(.clean) + try h.store.deleteSession() + h.guardFake.sleepDisabled = false + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive) + XCTAssertNil(m.pendingEnd, "the adopted end left the earlier end pending") + await m.start(duration: 600) + XCTAssertTrue(m.isActive, m.lastError ?? "") + XCTAssertEqual(h.guardFake.calls, ["disablesleep 1", "disablesleep 1"]) + } + + /// The same sequence, but the restore the adopted end runs fails and is + /// left to the armed agent. Nothing is left for this process to retry, + /// so that end settles the pending end too. + func testAnAdoptedEndLeftToTheAgentSettlesAnEndPendingOnAnUnreadableJournal() async throws { + let m = h.makeManager(retryDelay: 3600) + await m.start(duration: 3600) + try Data(#"{"sleepDisabledByUs": tru"#.utf8).write(to: h.home.paths.stateFile) + let outcome = await m.end(reason: .timer) + XCTAssertEqual(outcome, .journalUnreadable) + + var dirty = RuntimeState() + dirty.sleepDisabledByUs = true + try h.store.saveState(dirty) + try h.store.deleteSession() + h.guardFake.throwOn = ["disablesleep 0"] + await m.noticeAgentEnd() + + XCTAssertFalse(m.isActive) + XCTAssertEqual(try h.store.loadState()?.sleepDisabledByUs, true, "the failed restore stays journaled for the agent") + XCTAssertTrue(h.notifier.posts.last?.body.contains("The recovery agent retries every minute.") ?? false, "\(h.notifier.posts)") + XCTAssertNil(m.pendingEnd, "the adopted end left the earlier end pending") + } } diff --git a/Tests/InsomniaTests/RecoveryScriptTests.swift b/Tests/InsomniaTests/RecoveryScriptTests.swift index 624f2761..b147d305 100644 --- a/Tests/InsomniaTests/RecoveryScriptTests.swift +++ b/Tests/InsomniaTests/RecoveryScriptTests.swift @@ -11,13 +11,14 @@ private let backslash = "\\" /// /// Each test runs a private COPY of the production script against a /// throwaway INSOMNIA_HOME. The copy has its fixed tool-path constants -/// (sudo, pmset, ps, kill, sysctl, pgrep, pkill, osascript, launchctl, -/// defaults) and its app-bundle / sudoers paths rewritten to point inside -/// the fixture, so nothing privileged runs, no real process is signaled, no -/// real app's preferences are read or written, and no real home, -/// LaunchAgent, sudoers file, or installed app is read or written. plutil -/// and lockf are the real tools, and so is date, except for the backstop's -/// moved-aside stamp, which a test can freeze. The fakes record every call. +/// (sudo, pmset, ps, kill, sysctl, notifyutil, ioreg, pgrep, pkill, +/// osascript, launchctl, defaults) and its app-bundle / sudoers paths rewritten to +/// point inside the fixture, so nothing privileged runs, no real process is +/// signaled, no real app's preferences are read or written, and no real +/// home, LaunchAgent, sudoers file, or installed app is read or written. +/// plutil, lockf and cmp are the real tools, and so is date, except for the +/// backstop's moved-aside stamp, which a test can freeze. The fakes record +/// every call. final class RecoveryScriptTests: XCTestCase { private var fx: ScriptFixture! @@ -28,6 +29,14 @@ final class RecoveryScriptTests: XCTestCase { override func tearDown() { fx.destroy() fx = nil + // The app tests here point INSOMNIA_HOME at the fixture. Whatever a + // test did, the next one must start on the loader's throwaway home, + // never on an unset variable that resolves the real ~/Library. + let home = ProcessTestHome.current + if home != ProcessTestHome.root.path { + setenv(Paths.environmentKey, ProcessTestHome.root.path, 1) + XCTFail("the test left INSOMNIA_HOME at \(home ?? "unset"), not \(ProcessTestHome.root.path)") + } } // MARK: - backstop.sh @@ -74,17 +83,676 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.state), "must not seed a journal") } - func testValidFutureSessionIsNoOpWithoutForce() throws { + // MARK: backstop.sh: a valid session is live only while the app and the floors say so + + private let liveJournal = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"# + private let sleepRestored = "sudo -n PMSET -a disablesleep 0" + private let batteryRead = "pmset -g batt" + private let thermalRead = "notifyutil -g com.apple.system.thermalpressurelevel" + private let batteryServiceRead = "ioreg -r -c AppleSmartBattery -d 1" + + /// A session with an hour left and sleep journaled as ours: what the + /// backstop sees every minute while the app runs. On `fx`, or on `f` + /// when given (a row of `runBackstopRows`), as in the helpers below. + private func writeLiveSession(_ f: ScriptFixture? = nil) throws { + let f = f ?? fx! + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try f.writeState(liveJournal) + } + + private func calls(_ f: ScriptFixture? = nil) -> [String] { + let f = f ?? fx! + return f.calls().map { $0.replacingOccurrences(of: f.fakePmset, with: "PMSET") } + } + + private func assertSessionEnded(_ r: (status: Int32, stdout: String, stderr: String), reason: String, in f: ScriptFixture? = nil, file: StaticString = #filePath, line: UInt = #line) throws { + let f = f ?? fx! + XCTAssertEqual(r.status, 0, r.stderr + f.log(), file: file, line: line) + XCTAssertTrue(calls(f).contains(sleepRestored), "\(calls(f))", file: file, line: line) + XCTAssertEqual(try f.stateJSON()["sleepDisabledByUs"] as? Bool, false, file: file, line: line) + XCTAssertFalse(f.exists(f.session), "session.json must go with the session", file: file, line: line) + XCTAssertTrue(f.log().contains("ending the session before its deadline"), f.log(), file: file, line: line) + XCTAssertTrue(f.log().contains(reason), f.log(), file: file, line: line) + } + + private func assertSessionKept(_ r: (status: Int32, stdout: String, stderr: String), in f: ScriptFixture? = nil, file: StaticString = #filePath, line: UInt = #line) throws { + let f = f ?? fx! + XCTAssertEqual(r.status, 0, r.stderr + f.log(), file: file, line: line) + XCTAssertFalse(calls(f).contains { $0.hasPrefix("sudo") || $0.hasPrefix("kill -CONT") }, "\(calls(f))", file: file, line: line) + XCTAssertEqual(try String(contentsOf: f.state, encoding: .utf8), liveJournal, "journal must not be rewritten", file: file, line: line) + XCTAssertTrue(f.exists(f.session), "the session must stand", file: file, line: line) + } + + /// Runs backstop.sh once per row, each on a fixture of its own + /// (`ScriptFixture.concurrentRow`), several at a time: `prepare` sets + /// the row's fixture up, and the app's alive lock is held on it through + /// the run when `alive` says so. Returns each row with its fixture and + /// result, in order; the caller destroys the fixtures. The locks are + /// let go once every run has ended, and the fixtures made are + /// destroyed here when anything before that throws. + private func runBackstopRows(_ rows: [Row], alive: (Row) -> Bool, args: (Row) -> [String] = { _ in [] }, + prepare: (Row, ScriptFixture) throws -> Void) async throws -> [(row: Row, f: ScriptFixture, r: (status: Int32, stdout: String, stderr: String))] { + var fixtures: [ScriptFixture] = [] + var locks: [AppAliveLock] = [] + do { + for row in rows { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try prepare(row, f) + if alive(row) { locks.append(try f.holdAliveLock()) } + } + let results = try await ScriptFixture.runAll(zip(rows, fixtures).map { $1.launch($1.backstop, args($0)) }) + locks.forEach { $0.release() } + return zip(zip(rows, fixtures), results).map { (row: $0.0, f: $0.1, r: $1) } + } catch { + locks.forEach { $0.release() } + fixtures.forEach { $0.destroy() } + throw error + } + } + + /// The app holds the alive lock, the Mac is on AC power and cool: the + /// minute tick reads the battery and the heat and leaves the session + /// alone, without a word in the log. + func testValidSessionWithTheAppAliveAndAHealthyMachineIsLeftAlone() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + + let r = try fx.run(fx.backstop) + + try assertSessionKept(r) + XCTAssertEqual(calls(), [batteryRead, thermalRead], "reads only, nothing privileged") + XCTAssertFalse(fx.exists(fx.logFile), "a healthy minute must not spam the log") + } + + /// Nobody holds the alive lock: the app crashed, was force-quit or has + /// not started yet. The deadline no longer matters; the session ends + /// as if --force had been given, and the battery and heat are not even + /// read. + func testAppNotRunningEndsAValidSession() throws { + try writeLiveSession() + + let r = try fx.run(fx.backstop) + + try assertSessionEnded(r, reason: "Insomnia is not running") + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertTrue(fx.exists(fx.alive), "the probe creates the lock file and keeps it (lockf -k)") + XCTAssertTrue(fx.log().contains("journal cleared"), fx.log()) + } + + /// The probe gives the lock back when it exits: the app can take it + /// right after a run, and the next run then sees it held. + func testAliveProbeReleasesTheLockItTook() throws { + try writeLiveSession() + XCTAssertEqual(try fx.run(fx.backstop).status, 0) + XCTAssertFalse(fx.exists(fx.session)) + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + try fx.clearCalls() + try assertSessionKept(try fx.run(fx.backstop)) + } + + /// The lock the app takes (AppAliveLock.swift) is the lock the script + /// probes: held in this process, the session stands; released, as the + /// kernel does when the process dies, the next run ends it. + func testAppAliveLockTakenByTheAppIsSeenByTheBackstop() throws { + try writeLiveSession() + let lock = AppAliveLock(url: fx.alive) + XCTAssertTrue(try lock.tryAcquire()) + + try assertSessionKept(try fx.run(fx.backstop)) + + lock.release() + try fx.clearCalls() + try assertSessionEnded(try fx.run(fx.backstop), reason: "Insomnia is not running") + } + + /// A valid session whose journal is clean (the app died between writing + /// session.json and pmset) is removed without running anything. + func testAppNotRunningWithACleanJournalRemovesTheSessionOnly() throws { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) - let dirty = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"# - try fx.writeState(dirty) + try fx.writeState(#"{"sleepDisabledByUs":false,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) let r = try fx.run(fx.backstop) XCTAssertEqual(r.status, 0, r.stderr) - XCTAssertEqual(fx.calls(), []) - XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), dirty) - XCTAssertTrue(fx.exists(fx.session)) + XCTAssertEqual(calls(), []) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertTrue(fx.log().contains("Insomnia is not running"), fx.log()) + XCTAssertTrue(fx.log().contains("journal already clean"), fx.log()) + } + + /// Greptile 4219151866: a journal the app does not load stops the run + /// before a valid session is ended, whatever ends it (the battery with + /// the app alive, config.json missing or read, the app not running, + /// --force): session.json and the journal stay byte for byte, no end is + /// recorded anywhere, nothing runs, and the log says why. The app's + /// decoder refuses each of these journals. The review's trace is the + /// first journal with config.json missing, at 20%. Once the journal is + /// repaired the next run ends the session. A journal with a key written + /// twice, which the app reads by its first copy, ends the session: the + /// next test. Each journal and mode runs on a fixture of its own, + /// several at a time; the repair is on the last one's. + func testAJournalTheAppDoesNotLoadKeepsAValidSessionTheRunWouldEnd() async throws { + let journals = [ + #"{"sleepDisabledByUs":true,"frozenProcesses":"bad","sessionCutoffs":"30 false"}"#, + #"{"sleepDisabledByUs":true,"frozenProcesses":[{"pid":2147483648}],"sessionCutoffs":"30 false"}"#, + #"{"sleepDisabledByUs":true,"sessionCutoffs":"30 false","#, + ] + let modes: [(name: String, alive: Bool, config: String?, args: [String], kept: String)] = [ + ("app alive, config.json missing", true, nil, [], "its cutoffs are not read and it is not ended"), + ("app alive, config.json read", true, #"{"endFloor":30,"thermalRules":false}"#, [], "it is not ended"), + ("app not running", false, nil, [], "it is not ended"), + ("--force", true, nil, ["--force"], "it is not ended"), + ] + var rows: [(journal: String, mode: (name: String, alive: Bool, config: String?, args: [String], kept: String))] = [] + for journal in journals { + XCTAssertThrowsError(try Store.makeDecoder().decode(RuntimeState.self, from: Data(journal.utf8)), journal) + for mode in modes { + rows.append((journal, mode)) + } + } + var sessions: [Data] = [] + let ran = try await runBackstopRows(rows, alive: { $0.mode.alive }, args: { $0.mode.args }) { row, f in + if let config = row.mode.config { try f.writeConfig(config) } + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + sessions.append(try Data(contentsOf: f.session)) + try f.writeState(row.journal) + f.setBattery(f.battery(source: "Battery Power", percent: 20)) + } + defer { ran.forEach { $0.f.destroy() } } + + for ((row, f, r), session) in zip(ran, sessions) { + let label = "\(row.mode.name), \(row.journal)" + XCTAssertEqual(r.status, 1, "\(label): \(f.log())") + XCTAssertEqual(try Data(contentsOf: f.session), session, label) + XCTAssertEqual(try String(contentsOf: f.state, encoding: .utf8), row.journal, label) + XCTAssertEqual(try f.contents(of: f.home).filter { $0.hasPrefix("ended-session") }, [], label) + XCTAssertEqual(((try? f.contents(of: f.logFile.deletingLastPathComponent())) ?? []).filter { $0.hasPrefix("ended-session") }, [], label) + let lockBytes = f.exists(f.lock) ? try Data(contentsOf: f.lock) : Data() + XCTAssertFalse(String(decoding: lockBytes, as: UTF8.self).contains("ended-session-v1"), label) + XCTAssertFalse(calls(f).contains { $0.hasPrefix("sudo") || $0.hasPrefix("kill") }, "\(label): \(calls(f))") + XCTAssertTrue(f.log().contains("is unreadable or malformed; nothing undone, evidence kept"), "\(label): \(f.log())") + XCTAssertTrue(f.log().contains("\(f.session.path) is kept as it is: \(row.mode.kept) while"), "\(label): \(f.log())") + } + + let f = try XCTUnwrap(ran.last?.f) + try? FileManager.default.removeItem(at: f.config) + try f.writeState(#"{"sleepDisabledByUs":true,"frozenProcesses":[],"sessionCutoffs":"30 false"}"#) + try f.clearCalls() + let r = try f.run(f.backstop) + try assertSessionEnded(r, reason: "Insomnia is not running", in: f) + } + + /// The controls: the same session with a journal the app loads, as this + /// build writes it or as an older one did (frozenPids, no record of the + /// cutoffs), ends in each mode. With the app alive and config.json + /// missing, the record's 30% floor ends it at 20%, and the defaults' + /// 10% one at 9% only. A key written twice, as such or once with an + /// escape, is read by its first copy, as the app reads it: those + /// journals end the session in each mode of the test above, and the + /// journal published holds the key once, with that copy's value. Each + /// case runs on a fixture of its own, all of them several at a time. + func testAJournalTheAppLoadsLetsTheRunEndAValidSession() async throws { + let current = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"savedAudioOutputs":[],"appNapOverrides":[],"sessionCutoffs":"30 false"}"# + let legacy = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenPids":[],"dockerFrozen":false}"# + let cases: [(journal: String, alive: Bool, args: [String], battery: Int, reason: String?)] = [ + (current, true, [], 20, "below the 30% end floor"), + (current, true, [], 31, nil), + (legacy, true, [], 9, "below the 10% end floor"), + (legacy, true, [], 20, nil), + (current, false, [], 20, "Insomnia is not running"), + (legacy, false, [], 20, "Insomnia is not running"), + (current, true, ["--force"], 20, "forced end of session"), + (legacy, true, ["--force"], 20, "forced end of session"), + ] + for c in cases { + XCTAssertNoThrow(try Store.makeDecoder().decode(RuntimeState.self, from: Data(c.journal.utf8)), "\(c.args) alive \(c.alive) at \(c.battery)%: \(c.journal)") + } + + // pids: the frozen processes the app reads, by pid (nil: no + // frozenProcesses key). Pid 5 has no startedAt, so there is no + // identity to check and nothing signals it; its entry stays, and + // the run exits 1 with the journal kept for it. + let b = backslash + let twice: [(journal: String, pids: [Int32]?)] = [ + (#"{"sleepDisabledByUs":true,"sleepDisabledByUs":false,"frozenProcesses":[],"sessionCutoffs":"30 false"}"#, []), + (#"{"sleepDisabledByUs":true,"frozenProcesses":[{"pid":5,"pid":6}],"sessionCutoffs":"30 false"}"#, [5]), + (#"{"sleepDisabledByUs":true,"sleepDisabledBy\#(b)u0055s":true,"sessionCutoffs":"30 false"}"#, nil), + ] + let modes: [(name: String, alive: Bool, config: String?, args: [String], reason: String)] = [ + ("app alive, config.json missing", true, nil, [], "below the 30% end floor"), + ("app alive, config.json read", true, #"{"endFloor":30,"thermalRules":false}"#, [], "below the 30% end floor"), + ("app not running", false, nil, [], "Insomnia is not running"), + ("--force", true, nil, ["--force"], "forced end of session"), + ] + // Rows: a case above (twice nil), or a journal of `twice` in a mode. + var rows: [(journal: String, alive: Bool, config: String?, args: [String], battery: Int, reason: String?, twice: (pids: [Int32]?, mode: String)?)] = [] + for c in cases { + rows.append((c.journal, c.alive, nil, c.args, c.battery, c.reason, nil)) + } + for row in twice { + let decoded = try Store.makeDecoder().decode(RuntimeState.self, from: Data(row.journal.utf8)) + XCTAssertTrue(decoded.sleepDisabledByUs, row.journal) + XCTAssertEqual(decoded.frozenProcesses.map(\.pid), row.pids ?? [], row.journal) + for mode in modes { + rows.append((row.journal, mode.alive, mode.config, mode.args, 20, mode.reason, (row.pids, mode.name))) + } + } + let ran = try await runBackstopRows(rows, alive: { $0.alive }, args: { $0.args }) { row, f in + if let config = row.config { try f.writeConfig(config) } + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try f.writeState(row.journal) + f.setBattery(f.battery(source: "Battery Power", percent: row.battery)) + } + defer { ran.forEach { $0.f.destroy() } } + + for (row, f, r) in ran { + guard let twice = row.twice else { + let label = "\(row.args) alive \(row.alive) at \(row.battery)%: \(row.journal)" + if let reason = row.reason { + XCTAssertEqual(r.status, 0, "\(label): \(f.log())") + XCTAssertTrue(calls(f).contains(sleepRestored), "\(label): \(calls(f)) \(f.log())") + XCTAssertEqual(try f.stateJSON()["sleepDisabledByUs"] as? Bool, false, label) + XCTAssertFalse(f.exists(f.session), label) + XCTAssertTrue(f.log().contains(reason), "\(label): \(f.log())") + XCTAssertFalse(f.log().contains("unreadable or malformed"), "\(label): \(f.log())") + } else { + XCTAssertEqual(r.status, 0, "\(label): \(f.log())") + XCTAssertTrue(f.exists(f.session), label) + XCTAssertEqual(try String(contentsOf: f.state, encoding: .utf8), row.journal, label) + } + continue + } + let label = "\(twice.mode), \(row.journal)" + let reason = try XCTUnwrap(row.reason, label) + let kept = twice.pids ?? [] + XCTAssertEqual(r.status, kept.isEmpty ? 0 : 1, "\(label): \(f.log())") + for pid in kept { + XCTAssertTrue(f.log().contains("pid \(pid) was journaled without identity; not signaled, kept for the app to resolve"), "\(label): \(f.log())") + } + XCTAssertTrue(calls(f).contains(sleepRestored), "\(label): \(calls(f)) \(f.log())") + XCTAssertFalse(calls(f).contains { $0.hasPrefix("kill") }, "\(label): \(calls(f))") + XCTAssertFalse(f.exists(f.session), label) + XCTAssertTrue(f.log().contains(reason), "\(label): \(f.log())") + XCTAssertFalse(f.log().contains("unreadable or malformed"), "\(label): \(f.log())") + let text = try String(contentsOf: f.state, encoding: .utf8) + XCTAssertEqual(text.components(separatedBy: "sleepDisabledBy").count, 2, "\(label): \(text)") + XCTAssertEqual(text.components(separatedBy: #""pid""#).count, (twice.pids?.count ?? 0) + 1, "\(label): \(text)") + let published = try f.stateJSON() + XCTAssertEqual(published["sleepDisabledByUs"] as? Bool, false, label) + XCTAssertEqual(published["frozenProcesses"] as? [[String: Int]], twice.pids.map { $0.map { ["pid": Int($0)] } }, label) + XCTAssertEqual(published["sessionCutoffs"] as? String, "30 false", label) + } + XCTAssertEqual(rows.filter { $0.twice != nil }.count, 12, "three journals with a key written twice, in four modes each") + } + + func testBatteryBelowTheEndFloorOnBatteryPowerEndsTheSession() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + fx.setBattery(fx.battery(source: "Battery Power", percent: 9)) + + let r = try fx.run(fx.backstop) + + try assertSessionEnded(r, reason: "battery at 9% on battery power, below the 10% end floor") + XCTAssertEqual(calls(), [batteryRead, sleepRestored], "no thermal read once the battery has decided") + } + + /// Strict less-than, as in FloorRules.swift: at the floor is not below + /// it. On AC power the charge does not matter at all. + func testBatteryAtTheFloorOrOnACPowerKeepsTheSession() async throws { + let rows: [(source: String, percent: Int, state: String)] = [("Battery Power", 10, "discharging"), ("AC Power", 3, "charging"), ("AC Power", 0, "charging")] + let ran = try await runBackstopRows(rows, alive: { _ in true }) { row, f in + try writeLiveSession(f) + f.setBattery(f.battery(source: row.source, percent: row.percent, state: row.state)) + } + defer { ran.forEach { $0.f.destroy() } } + for (_, f, r) in ran { + try assertSessionKept(r, in: f) + } + } + + /// Fail closed: a battery that is there but cannot be read, or a pmset + /// that fails, ends the session; sleep must not stay disabled on a guess. + func testBatteryUnreadableOrPmsetFailingEndsTheSession() async throws { + let cases: [(output: String, reason: String)] = [ + ("FAIL", "battery state unreadable (pmset -g batt exit 1)"), + ("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t(no estimate) present: true\n", "battery present but unreadable"), + ("Now drawing from 'UPS Power'\n -InternalBattery-0 (id=1)\t50%; discharging; present: true\n", "battery present but unreadable"), + (" -InternalBattery-0 (id=1)\t50%; discharging; present: true\n", "battery present but unreadable"), + ] + let ran = try await runBackstopRows(cases, alive: { _ in true }) { c, f in + try writeLiveSession(f) + f.setBattery(c.output) + } + defer { ran.forEach { $0.f.destroy() } } + for (c, f, r) in ran { + try assertSessionEnded(r, reason: c.reason, in: f) + } + } + + /// No InternalBattery line and no AppleSmartBattery service is a + /// desktop: there is no battery rule, and the thermal check still runs. + func testDesktopWithoutABatteryHasNoBatteryRule() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + fx.setBattery("Now drawing from 'AC Power'\n") + + let r = try fx.run(fx.backstop) + + try assertSessionKept(r) + XCTAssertEqual(calls(), [batteryRead, batteryServiceRead, thermalRead]) + } + + /// A laptop whose power source list lost its battery row still has the + /// AppleSmartBattery service, as the app's PowerMonitor.classify checks. + /// Its level is unknown, so the session ends unless the driver reports a + /// charger. An ioreg that fails or hangs cannot show a desktop either. + /// The hung ioreg runs on `fx`, whose 1 s limit it waits out; the other + /// modes run on fixtures of their own, several at a time. + func testABatteryMissingFromPmsetIsJudgedByTheBatteryService() async throws { + let rows: [(mode: String, reason: String?)] = [ + ("BATTERY", "battery present (AppleSmartBattery) but missing from pmset -g batt, and no charger reported"), + ("BATTERY_NOKEY", "battery present (AppleSmartBattery) but missing from pmset -g batt, and no charger reported"), + ("FAIL", "no battery in pmset -g batt, and ioreg exit 1 could not show there is none"), + ("BATTERY_AC", nil), + ] + let ran = try await runBackstopRows(rows, alive: { _ in true }) { c, f in + try writeLiveSession(f) + f.setBattery("Now drawing from 'AC Power'\n") + f.setBatteryService(c.mode) + } + defer { ran.forEach { $0.f.destroy() } } + for (c, f, r) in ran { + if let reason = c.reason { + try assertSessionEnded(r, reason: reason, in: f) + XCTAssertTrue(calls(f).contains(batteryServiceRead), "\(c.mode): \(calls(f))") + } else { + try assertSessionKept(r, in: f) + XCTAssertEqual(calls(f), [batteryRead, batteryServiceRead, thermalRead]) + } + } + + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + fx.setBattery("Now drawing from 'AC Power'\n") + fx.setBatteryService("HANG") + try assertSessionEnded(try fx.run(fx.backstop), reason: "no battery in pmset -g batt, and ioreg did not finish within 1s") + XCTAssertTrue(calls().contains(batteryServiceRead), "\(calls())") + } + + /// endFloor comes from config.json like the app's; 0 disables the rule; + /// a value that is not a whole number falls back to the default 10. + func testEndFloorIsReadFromConfigAndZeroDisablesIt() async throws { + let rows: [(config: String, percent: Int, reason: String?)] = [ + (#"{"endFloor": 30}"#, 25, "battery at 25% on battery power, below the 30% end floor"), + (#"{"endFloor": 0}"#, 1, nil), + (#"{"endFloor": "ten"}"#, 9, "below the 10% end floor"), + ("not json at all", 9, "below the 10% end floor"), + ] + let ran = try await runBackstopRows(rows, alive: { _ in true }) { c, f in + try writeLiveSession(f) + try f.writeConfig(c.config) + f.setBattery(f.battery(source: "Battery Power", percent: c.percent)) + } + defer { ran.forEach { $0.f.destroy() } } + for (c, f, r) in ran { + if let reason = c.reason { + try assertSessionEnded(r, reason: reason, in: f) + } else { + try assertSessionKept(r, in: f) + } + } + } + + /// JSONDecoder reads a number that is exactly an integer as an Int, so + /// 30.0 and 3e1 are a 30% floor in the app and 0.0 turns the rule off; a + /// fraction fails the app's decode, which then uses the default 10. The + /// app clamps the floor to 0...95 (Config.normalizeFloors). The backstop + /// enforces the same floor in every case. + func testNumericEndFloorsAreReadAsTheAppDecodesAndClampsThem() async throws { + let ended: [(config: String, percent: Int, floor: Int)] = [ + (#"{"configVersion": 2, "endFloor": 30.0}"#, 25, 30), + (#"{"endFloor": 3e1}"#, 25, 30), + (#"{"endFloor": 30.5}"#, 9, 10), + (#"{"endFloor": 30.0000001}"#, 9, 10), + (#"{"endFloor": 200}"#, 90, 95), + ] + let kept: [(config: String, percent: Int)] = [ + (#"{"endFloor": 30.5}"#, 25), + (#"{"endFloor": 0.0}"#, 1), + (#"{"endFloor": -5}"#, 1), + ] + let rows: [(config: String, percent: Int, floor: Int?)] = ended.map { ($0.config, $0.percent, $0.floor) } + kept.map { ($0.config, $0.percent, nil) } + let ran = try await runBackstopRows(rows, alive: { _ in true }) { c, f in + try writeLiveSession(f) + try f.writeConfig(c.config) + f.setBattery(f.battery(source: "Battery Power", percent: c.percent)) + } + defer { ran.forEach { $0.f.destroy() } } + for (c, f, r) in ran { + if let floor = c.floor { + try assertSessionEnded(r, reason: "battery at \(c.percent)% on battery power, below the \(floor)% end floor", in: f) + } else { + try assertSessionKept(r, in: f) + } + } + } + + /// With the floor off nothing is read, so a failing pmset cannot end a + /// session the user exempted from the battery rule. + func testEndFloorZeroSkipsTheBatteryReadSoAFailingPmsetCannotEnd() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.writeConfig(#"{"endFloor": 0}"#) + fx.setBattery("FAIL") + + let r = try fx.run(fx.backstop) + + try assertSessionKept(r) + XCTAssertEqual(calls(), [thermalRead], "no battery read with the floor off") + } + + /// A JSON string is not the Int or Bool the app decodes: "30" and + /// "false" fall back to the defaults here as they do in the app, so both + /// sides enforce the same floor and the same thermal rule. + func testStringTypedConfigValuesAreIgnoredLikeTheAppDoes() async throws { + let rows: [(config: String, battery: (source: String, percent: Int, state: String), thermal: String?, reason: String?)] = [ + (#"{"endFloor": "30"}"#, ("Battery Power", 25, "discharging"), nil, nil), + (#"{"endFloor": "30"}"#, ("Battery Power", 9, "discharging"), nil, "below the 10% end floor"), + (#"{"thermalRules": "false", "endFloor": 10.0}"#, ("AC Power", 50, "charging"), "4", "thermal pressure level 4"), + ] + let ran = try await runBackstopRows(rows, alive: { _ in true }) { c, f in + try writeLiveSession(f) + try f.writeConfig(c.config) + f.setBattery(f.battery(source: c.battery.source, percent: c.battery.percent, state: c.battery.state)) + if let thermal = c.thermal { f.setThermal(thermal) } + } + defer { ran.forEach { $0.f.destroy() } } + for (c, f, r) in ran { + if let reason = c.reason { + try assertSessionEnded(r, reason: reason, in: f) + } else { + try assertSessionKept(r, in: f) + } + } + } + + /// The reads are bounded like the undo commands (COMMAND_TIMEOUT_SECONDS, + /// 1 s in this fixture). A battery read that hangs is terminated and + /// counts as unreadable: the session ends. A thermal read that hangs + /// only warns. + func testHungReadsAreBoundedBatteryFailsClosedThermalWarns() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + + try writeLiveSession() + fx.setBattery("HANG") + var started = Date() + try assertSessionEnded(try fx.run(fx.backstop), reason: "pmset -g batt did not finish within 1s") + XCTAssertLessThan(Date().timeIntervalSince(started), 20, "the hung read must not hold the run for its whole minute") + XCTAssertTrue(fx.log().contains("did not finish within 1s; terminated with SIGTERM"), fx.log()) + XCTAssertFalse(fx.calls().contains { $0.hasPrefix("kill") }, "the read is signaled as this shell's job, never by a pid handed to $KILL: \(fx.calls())") + XCTAssertTrue(fx.hungReadIsGone(), "the hung read was stopped and reaped") + let seen = try String(contentsOf: fx.root.appendingPathComponent("read.files"), encoding: .utf8) + .split(separator: "\n").map(String.init) + XCTAssertEqual(seen.count, 1, "\(seen)") + // The battery read is the run's second: with no config.json, the + // cutoffs the journal records for the session are read first. + XCTAssertTrue(seen.allSatisfy { $0.hasSuffix(".2.out") }, "a read has its output file and no .pid or .rc status files: \(seen)") + + try writeLiveSession() + try fx.clearCalls() + fx.setBattery(fx.battery(source: "AC Power", percent: 100, state: "charged")) + fx.setThermal("HANG") + started = Date() + try assertSessionKept(try fx.run(fx.backstop)) + XCTAssertLessThan(Date().timeIntervalSince(started), 20) + XCTAssertTrue(fx.log().contains("thermal pressure level unreadable"), fx.log()) + let leftovers = try FileManager.default.contentsOfDirectory(atPath: fx.home.path).filter { $0.hasPrefix(".backstop.") } + XCTAssertEqual(leftovers, [], "status and capture files are cleaned up") + } + + /// A read never holds the recovery lock: it starts with fd 9 closed, so + /// it does not have it while the run holds the lock. + /// The fake looks with lsof, which can take seconds on a busy machine, + /// so this run gets a 30 s time limit; the read answers as soon as it + /// has looked, so the limit never fires. + func testReadsRunWithoutTheLockDescriptor() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + try fx.setCommandTimeout(30) + fx.setThermal("CHECK_FD9") + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertTrue(fx.calls().contains("notifyutil checked fd 9"), "\(fx.calls())") + XCTAssertFalse(fx.calls().contains { $0.hasSuffix("had fd 9") }, "the read must not inherit the lock: \(fx.calls())") + XCTAssertFalse(fx.log().contains("did not finish"), fx.log()) + } + + /// A read that ignores SIGTERM and leaves a child behind is killed, and + /// the lock is free the moment the run exits, so the next minute's run + /// takes it and can still end the session. + func testReadThatIgnoresSigtermLeavesTheLockToTheNextRun() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + fx.setThermal("IGNORE_TERM") + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertTrue(fx.log().contains("ignored SIGTERM; sent SIGKILL"), fx.log()) + XCTAssertFalse(fx.calls().contains { $0.hasPrefix("kill") }, "the read is signaled as this shell's job, never by a pid handed to $KILL: \(fx.calls())") + XCTAssertTrue(fx.hungReadIsGone(), "the read that ignored SIGTERM was killed and reaped") + XCTAssertTrue(fx.log().contains("thermal pressure level unreadable"), fx.log()) + XCTAssertTrue(try fx.lockIsFree(), "nothing the read started holds the lock") + + try fx.clearCalls() + fx.setThermal("3") + try assertSessionEnded(try fx.run(fx.backstop), reason: "thermal pressure level 3") + let leftovers = try FileManager.default.contentsOfDirectory(atPath: fx.home.path).filter { $0.hasPrefix(".backstop.") } + XCTAssertEqual(leftovers, []) + } + + /// The first read of a run cleans up like the first undo command (see + /// testStatusFilesGoWithTheirCallAndLeftoversOnlyUnderTheRunsOwnLock): + /// a run that took the lock itself removes the status and output files + /// earlier runs left, and a run that shares its caller's lock leaves + /// them, since an earlier run under that lock may still have a + /// supervisor waiting for its command. The read's own output file goes + /// with its call either way. + func testReadsRemoveLeftoversOnlyUnderTheRunsOwnLock() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + let leftovers = [".backstop.4242.1.out", ".backstop.4242.1.pid", ".backstop.4242.1.rc"] + for name in leftovers { + try "4242\n".write(to: fx.home.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + try Data().write(to: fx.lock) + let sharing = fx.root.appendingPathComponent("holder-then-backstop.sh") + try """ + #!/bin/bash + set -eu + exec 9<>"\(fx.lock.path)" + /usr/bin/lockf -t 0 9 + /bin/bash "\(fx.backstop.path)" + """.write(to: sharing, atomically: true, encoding: .utf8) + + try assertSessionKept(try fx.run(sharing)) + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertEqual(try fx.backstopFiles(), leftovers) + + try assertSessionKept(try fx.run(fx.backstop)) + XCTAssertEqual(try fx.backstopFiles(), []) + } + + /// Levels 0 to 2 (nominal, moderate, heavy) keep the session; 3 and 4 + /// (trapping, sleeping) are what ProcessInfo reports as critical and + /// end it. + func testThermalPressureAtTrappingOrAboveEndsTheSession() async throws { + let ran = try await runBackstopRows(Array(0...4), alive: { _ in true }) { level, f in + try writeLiveSession(f) + f.setThermal("\(level)") + } + defer { ran.forEach { $0.f.destroy() } } + for (level, f, r) in ran { + if level <= 2 { + try assertSessionKept(r, in: f) + } else { + try assertSessionEnded(r, reason: "thermal pressure level \(level) (critical", in: f) + XCTAssertEqual(calls(f), [batteryRead, thermalRead, sleepRestored]) + } + } + } + + /// Heat is read best effort: a failing or nonsensical notifyutil is a + /// warning in the log, never an end on its own. + func testThermalUnreadableWarnsWithoutEndingTheSession() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + for mode in ["FAIL", "GARBAGE"] { + try writeLiveSession() + fx.setThermal(mode) + try assertSessionKept(try fx.run(fx.backstop)) + XCTAssertTrue(fx.log().contains("thermal pressure level unreadable"), fx.log()) + XCTAssertFalse(fx.log().contains("ending the session before its deadline"), fx.log()) + } + } + + func testThermalRulesOffIgnoresCriticalHeat() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.writeConfig(#"{"thermalRules": false}"#) + fx.setThermal("4") + + let r = try fx.run(fx.backstop) + + try assertSessionKept(r) + XCTAssertEqual(calls(), [batteryRead], "with the rule off the level is not even read") + } + + /// A thermal end is logged with the level, and the log names the reason + /// in the same line as the restore, so one grep tells the story. + func testCutoffReasonIsInTheRestoreLogLine() throws { + try writeLiveSession() + let app = try fx.holdAliveLock() + defer { app.release() } + fx.setThermal("3") + + _ = try fx.run(fx.backstop) + + XCTAssertTrue(fx.log().contains("session ended early, thermal pressure level 3 (critical from 3 up) (endsAt="), fx.log()) + XCTAssertTrue(fx.log().contains("restoring from journal"), fx.log()) } func testForceEndsValidSession() throws { @@ -99,6 +767,991 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.session)) } + // MARK: backstop.sh: an early end is final even when its undo is not + + private let journalWithSavedBrightness = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"savedDisplayBrightness":0.6}"# + + /// Saved brightness is the app's to restore, so the run that ends the + /// session of an app that died cannot clear the journal. The session + /// ends anyway: session.json goes, and what is left stays journaled. + func testEarlyEndWithAnUndoOnlyTheAppCanFinishStillRemovesTheSession() throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try fx.writeState(journalWithSavedBrightness) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertFalse(fx.exists(fx.session), "a relaunched app must find no session to resume") + let s = try fx.stateJSON() + XCTAssertEqual(s["sleepDisabledByUs"] as? Bool, false) + XCTAssertEqual((s["savedDisplayBrightness"] as? NSNumber)?.doubleValue, 0.6, "kept for the app") + XCTAssertTrue(fx.log().contains("Insomnia is not running"), fx.log()) + } + + /// A pmset that fails leaves sleep journaled, not the session. The next + /// run finds no session to check and undoes what is journaled. + func testEarlyEndWithAFailingPmsetRemovesTheSessionAndTheNextRunFinishes() throws { + let app = try fx.holdAliveLock() + defer { app.release() } + try writeLiveSession() + fx.setBattery(fx.battery(source: "Battery Power", percent: 9)) + fx.setMode("sudo", "fail") + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true, "a failed pmset stays journaled") + XCTAssertTrue(fx.log().contains("journal kept dirty"), fx.log()) + + fx.setMode("sudo", "ok") + try fx.clearCalls() + let next = try fx.run(fx.backstop) + + XCTAssertEqual(next.status, 0, next.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no session left, so nothing is read before the undo") + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, false) + } + + /// --force (install.sh, uninstall.sh) ends a valid session the same way. + func testForcedEndWithAFailingPmsetStillRemovesTheSession() throws { + try writeLiveSession() + fx.setMode("sudo", "fail") + + let r = try fx.run(fx.backstop, ["--force"]) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true) + XCTAssertTrue(fx.log().contains("forced end of session"), fx.log()) + } + + /// An undo that hangs stops the run with the journal as read and the + /// lock with the live command. The session it ended is gone all the + /// same, so the app sees the end and nothing can resume it. + func testEarlyEndWhoseUndoHangsStillRemovesTheSession() throws { + try writeLiveSession() + fx.setMode("sudo", "ignore-term") + + let r = try fx.run(fx.backstop) + + XCTAssertNotEqual(r.status, 0) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), liveJournal, "journal unchanged while the command runs") + XCTAssertFalse(try fx.lockIsFree(), "the live command keeps the lock") + fx.releaseCommand() + XCTAssertTrue(try fx.waitUntilLockIsFree()) + } + + /// The relaunch the early end must not undo. The backstop ends the + /// session of an app that died; its pmset fails and the saved + /// brightness is the app's to restore. Insomnia launched afterwards + /// finds no session, so it does not disable sleep again: it restores + /// what the journal still holds and leaves it clean. + @MainActor + func testAppRelaunchedAfterAPartialEarlyEndRestoresInsteadOfResuming() async throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try fx.writeState(journalWithSavedBrightness) + fx.setMode("sudo", "fail") + XCTAssertEqual(try fx.run(fx.backstop).status, 1, fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + + // The app logs into the fixture, not ~/Library/Logs/Insomnia. + let restoreHome = pointInsomniaHome(at: fx.home) + defer { restoreHome() } + let paths = Paths.fromEnvironment() + let sleepGuard = FakeSleepGuard() + let display = FakeDisplayDimmer(brightness: 0) + let m = SessionManager( + paths: paths, + sleepGuard: sleepGuard, + processControl: FakeProcessControl(), + backstop: FakeBackstop(), + display: display, + clamshell: { false }, + recoveryLockTimeout: 2, + recoveryRetryDelay: 3600, + reassertDelay: .seconds(3600) + ) + await m.reconcile() + + XCTAssertNil(m.session, "the ended session must not come back") + XCTAssertFalse(sleepGuard.calls.contains("disablesleep 1"), "\(sleepGuard.calls)") + XCTAssertTrue(sleepGuard.calls.contains("disablesleep 0"), "\(sleepGuard.calls)") + XCTAssertEqual(display.sets.last, 0.6) + let after = try XCTUnwrap(try Store(paths: paths).loadState()) + XCTAssertFalse(after.isDirty, "\(after)") + } + + // MARK: backstop.sh: a session.json that cannot be removed + + /// session.json is immutable, so the run that ends the session cannot + /// remove it. It records the end in ended-session.json, a copy of the + /// file's bytes, and still restores sleep. Later runs end the session + /// again without reading the battery or the heat, even with the app + /// alive, and retry the removal; once it works the record goes too. + func testEndThatCannotRemoveSessionJSONRecordsItAndLaterRunsFinish() throws { + try writeLiveSession() + try setImmutable(fx.session, true) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "sleep is restored all the same") + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, false) + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertEqual(try Data(contentsOf: fx.endedSession), try Data(contentsOf: fx.session), "the record is the file's exact bytes") + XCTAssertNil(try fx.stateJSON()["endedSession"], "ended-session.json holds the record; the journal needs none") + XCTAssertTrue(fx.log().contains("its end is recorded in"), fx.log()) + + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 1, again.stderr + fx.log()) + XCTAssertEqual(calls(), [], "a session recorded as ended is not checked again") + XCTAssertTrue(fx.log().contains("already ended (recorded in"), fx.log()) + XCTAssertTrue(fx.exists(fx.endedSession)) + + try setImmutable(fx.session, false) + let last = try fx.run(fx.backstop) + + XCTAssertEqual(last.status, 0, last.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession), "the record goes with the file it copies") + } + + /// session.json cannot be removed and ended-session.json holds an + /// unrelated record that cannot be replaced. The end goes in the journal + /// instead (endedSession, the file's bytes in base64), and every other + /// key stays. Sleep is restored and its entry cleared: the record says + /// the session is over. Later runs end it again without reading the + /// battery or the heat, even with the app alive. The record stays once + /// the file is gone; only the app removes it, and it matches nothing. + func testEndThatCannotWriteTheEndRecordRecordsItInTheJournal() throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"futureKey":{"kept":1}}"#) + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + let marker = try Data(contentsOf: fx.session).base64EncodedString() + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + let journal = try fx.stateJSON() + XCTAssertEqual(journal["endedSession"] as? String, marker) + XCTAssertEqual(journal["sleepDisabledByUs"] as? Bool, false) + XCTAssertEqual((journal["futureKey"] as? [String: Any])?["kept"] as? Int, 1, "keys the agent does not own survive") + XCTAssertNoThrow(try Store(paths: Paths(root: fx.home)).loadState(), "the app still reads the journal") + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertEqual(try String(contentsOf: fx.endedSession, encoding: .utf8), "{}") + XCTAssertTrue(fx.log().contains("its end is recorded in \(fx.state.path) (endedSession) instead"), fx.log()) + + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 1, again.stderr + fx.log()) + XCTAssertEqual(calls(), [], "a session recorded as ended is not checked again") + XCTAssertTrue(fx.log().contains("already ended (recorded in \(fx.state.path) (endedSession))"), fx.log()) + + try setImmutable(fx.session, false) + let last = try fx.run(fx.backstop) + + XCTAssertEqual(last.status, 0, last.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try fx.stateJSON()["endedSession"] as? String, marker) + } + + /// The same with sleep that cannot be restored: the record is in the + /// journal before the undo is tried, so it is there although the undo + /// failed and sleepDisabledByUs stays for the retry. + func testEndRecordGoesInTheJournalBeforeTheUndo() throws { + try writeLiveSession() + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + fx.setMode("sudo", "fail") + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + let journal = try fx.stateJSON() + XCTAssertEqual(journal["endedSession"] as? String, try Data(contentsOf: fx.session).base64EncodedString()) + XCTAssertEqual(journal["sleepDisabledByUs"] as? Bool, true) + } + + /// No journal on disk: the record is a journal of its own, which the app + /// reads as clean. + func testEndRecordWithNoJournalWritesOne() throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [], "nothing journaled means nothing to undo") + let journal = try XCTUnwrap(try Store(paths: Paths(root: fx.home)).loadState()) + XCTAssertEqual(journal.endedSession, try Data(contentsOf: fx.session).base64EncodedString()) + XCTAssertFalse(journal.isDirty) + } + + /// The records aside in the fixture's home: names of the record's shape. + private func recordsAside() throws -> [String] { + try fx.contents(of: fx.home).filter { Paths.isEndedSessionAsideName($0) } + } + + /// Neither session.json, nor ended-session.json, nor the journal can be + /// written, but the folder takes new files: the end is recorded in a + /// new file beside them, ended-session.json.<8 letters or digits>, the + /// file's exact bytes, mode 0600. Sleep is restored; the journal keeps + /// sleepDisabledByUs because it cannot be written. Later runs end the + /// session again without the checks, even with the app alive, and keep + /// the record. Once the files can be changed, session.json and the + /// record go. + func testEndThatCanWriteOnlyANewFileRecordsItAside() throws { + try writeLiveSession() + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + try setImmutable(fx.state, true) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + let names = try recordsAside() + XCTAssertEqual(names.count, 1, "\(names)") + let record = fx.home.appendingPathComponent(try XCTUnwrap(names.first)) + XCTAssertEqual(try Data(contentsOf: record), try Data(contentsOf: fx.session), "the record is the file's exact bytes") + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: record.path)[.posixPermissions] as? Int, 0o600) + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true, "state.json cannot be written") + XCTAssertNil(try fx.stateJSON()["endedSession"]) + XCTAssertEqual(try String(contentsOf: fx.endedSession, encoding: .utf8), "{}") + XCTAssertTrue(fx.log().contains("could not remove \(fx.session.path) or record its end in \(fx.endedSession.path) or \(fx.state.path); its end is recorded in \(record.path) instead"), fx.log()) + + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 1, again.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no checks; the journal still asks for the restore") + XCTAssertTrue(fx.log().contains("already ended (recorded in \(record.path))"), fx.log()) + XCTAssertEqual(try recordsAside(), names, "a record that matches is kept and used again") + + for file in [fx.session, fx.endedSession, fx.state] { try setImmutable(file, false) } + let last = try fx.run(fx.backstop) + + XCTAssertEqual(last.status, 0, last.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try recordsAside(), [], "the record goes with the file it copies") + } + + /// The same files, the record aside cannot be created either (the + /// MKTEMP constant is /usr/bin/false here), and the recovery lock file + /// takes no record (LOCK_RECORD_MAX_BYTES is 0 here, a stand-in for a + /// write it refuses), nor does insomnia.log (LOG_RECORD_MAX_BYTES is 0): + /// nothing on disk says the session is over. Sleep + /// is restored anyway, but its journal entry stays as evidence and the + /// run exits 1. (The app then resumes nothing either: it writes the + /// journal before it resumes a session; JournaledSessionEndTests.) + func testEndThatCanNeitherRemoveNorRecordKeepsTheSleepEntry() throws { + try writeLiveSession() + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + try setImmutable(fx.state, true) + var text = try String(contentsOf: fx.backstop, encoding: .utf8) + text = try ScriptFixture.replaceOnce(text, "MKTEMP=/usr/bin/mktemp", with: "MKTEMP=/usr/bin/false") + text = try ScriptFixture.replaceOnce(text, "\nLOG_RECORD_MAX_BYTES=65536\n", with: "\nLOG_RECORD_MAX_BYTES=0\n") + try ScriptFixture.replaceOnce(text, "\nLOCK_RECORD_MAX_BYTES=1048576\n", with: "\nLOCK_RECORD_MAX_BYTES=0\n") + .write(to: fx.backstop, atomically: true, encoding: .utf8) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true, "evidence kept while the session still reads as valid") + XCTAssertNil(try fx.stateJSON()["endedSession"]) + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertEqual(try String(contentsOf: fx.endedSession, encoding: .utf8), "{}") + XCTAssertEqual(try recordsAside(), []) + XCTAssertEqual(try Data(contentsOf: fx.lock), Data()) + let log = fx.log() + XCTAssertTrue(log.contains("could not remove \(fx.session.path) or record its end in \(fx.endedSession.path), \(fx.state.path), a new file in \(fx.home.path) or \(logs.path), the recovery lock file \(fx.lock.path), or the log file \(fx.logFile.path)"), log) + XCTAssertTrue(log.contains("still journaled: sleepDisabledByUs is kept although sleep is restored"), log) + } + + /// The same files and no record aside, with a recovery lock file that + /// takes the record: it then holds the tag and the file's bytes in + /// base64, written in place, so its inode stays. Sleep is restored and + /// the run exits 1 while session.json stays. Later runs end the session + /// again without the checks, even with the app alive. Once the files + /// can be changed, session.json goes and the lock file is emptied, the + /// same inode still. + func testEndThatCanWriteOnlyTheLockFileRecordsItThere() throws { + try writeLiveSession() + FileManager.default.createFile(atPath: fx.lock.path, contents: nil) + let lockInode = try fx.inode(fx.lock) + try setImmutable(fx.session, true) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + try setImmutable(fx.state, true) + let text = try String(contentsOf: fx.backstop, encoding: .utf8) + try ScriptFixture.replaceOnce(text, "MKTEMP=/usr/bin/mktemp", with: "MKTEMP=/usr/bin/false") + .write(to: fx.backstop, atomically: true, encoding: .utf8) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + let record = "ended-session-v1 \(try Data(contentsOf: fx.session).base64EncodedString())\n" + XCTAssertEqual(try String(contentsOf: fx.lock, encoding: .utf8), record) + XCTAssertEqual(try fx.inode(fx.lock), lockInode) + XCTAssertEqual(try recordsAside(), []) + XCTAssertTrue(fx.log().contains("its end is recorded in the recovery lock file \(fx.lock.path) instead"), fx.log()) + + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 1, again.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no checks; the journal still asks for the restore") + XCTAssertTrue(fx.log().contains("already ended (recorded in \(fx.lock.path))"), fx.log()) + XCTAssertEqual(try String(contentsOf: fx.lock, encoding: .utf8), record, "a record that matches is kept and used again") + + for file in [fx.session, fx.endedSession, fx.state] { try setImmutable(file, false) } + let last = try fx.run(fx.backstop) + + XCTAssertEqual(last.status, 0, last.stderr + fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try Data(contentsOf: fx.lock), Data(), "the record goes with the file it copies") + XCTAssertEqual(try fx.inode(fx.lock), lockInode) + } + + private var logs: URL { fx.home.appendingPathComponent("Logs", isDirectory: true) } + + /// The records aside in the log folder. + private func recordsInTheLogFolder() throws -> [String] { + try fx.contents(of: logs).filter { Paths.isEndedSessionAsideName($0) } + } + + /// A folder that takes no new file (mode 0555; the lock file and the + /// log folder already exist): session.json cannot be removed and no + /// record can be written beside it. The record goes in the log folder, + /// the file's exact bytes, mode 0600. The restore still runs, but its + /// supervisor cannot write the status files either, so the run cannot + /// tell whether the command finished: it reports no result, keeps the + /// journal as it was and exits 1. Once the folder takes files again, a + /// run with the app alive ends the session without the checks, and + /// session.json and the record go. + func testEndInAFolderThatTakesNoNewFileRecordsItInTheLogFolder() throws { + try writeLiveSession() + FileManager.default.createFile(atPath: fx.lock.path, contents: nil) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let journal = try Data(contentsOf: fx.state) + try fx.clearCalls() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fx.home.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "the restore runs") + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + XCTAssertEqual(try recordsAside(), []) + let names = try recordsInTheLogFolder() + XCTAssertEqual(names.count, 1, "\(names)") + let record = logs.appendingPathComponent(try XCTUnwrap(names.first)) + XCTAssertEqual(try Data(contentsOf: record), try Data(contentsOf: fx.session), "the record is the file's exact bytes") + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: record.path)[.posixPermissions] as? Int, 0o600) + XCTAssertEqual(try Data(contentsOf: fx.state), journal, "the journal is kept as it was") + XCTAssertEqual(try Data(contentsOf: fx.lock), Data(), "the lock file is only the last place") + let log = fx.log() + XCTAssertTrue(log.contains("its end is recorded in \(record.path) instead"), log) + XCTAssertTrue(log.contains("its supervisor reported no result within"), log) + + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 0, again.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no checks; the journal still asks for the restore") + XCTAssertTrue(fx.log().contains("already ended (recorded in \(record.path))"), fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try recordsInTheLogFolder(), [], "the record goes with the file it copies") + } + + /// The same with the log folder refusing new files too (both mode + /// 0555): no new file can be written anywhere. The record goes in the + /// recovery lock file, which exists already, in place, before the + /// restore. The restore still runs, the run reports no result, keeps + /// the journal as it was and exits 1. Once both folders take files + /// again, a run with the app alive ends the session without the + /// checks, and session.json goes and the lock file is emptied, its + /// inode the same throughout. + func testEndWhereNeitherFolderTakesANewFileRecordsItInTheLockFile() throws { + try writeLiveSession() + FileManager.default.createFile(atPath: fx.lock.path, contents: nil) + let lockInode = try fx.inode(fx.lock) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: fx.logFile.path, contents: nil) + let journal = try Data(contentsOf: fx.state) + try fx.clearCalls() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: logs.path) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fx.home.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: logs.path) + } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "the restore runs") + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + XCTAssertEqual(try recordsAside(), []) + XCTAssertEqual(try recordsInTheLogFolder(), []) + XCTAssertEqual(try String(contentsOf: fx.lock, encoding: .utf8), "ended-session-v1 \(try Data(contentsOf: fx.session).base64EncodedString())\n") + XCTAssertEqual(try fx.inode(fx.lock), lockInode) + XCTAssertEqual(try Data(contentsOf: fx.state), journal, "the journal is kept as it was") + let log = fx.log() + XCTAssertTrue(log.contains("or a new file in \(fx.home.path) or \(logs.path); its end is recorded in the recovery lock file \(fx.lock.path) instead"), log) + XCTAssertTrue(log.contains("its supervisor reported no result within"), log) + + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: logs.path) + let app = try fx.holdAliveLock() + defer { app.release() } + try fx.clearCalls() + let again = try fx.run(fx.backstop) + + XCTAssertEqual(again.status, 0, again.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no checks; the journal still asks for the restore") + XCTAssertTrue(fx.log().contains("already ended (recorded in \(fx.lock.path))"), fx.log()) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertEqual(try Data(contentsOf: fx.lock), Data()) + XCTAssertEqual(try fx.inode(fx.lock), lockInode) + } + + /// The same, and neither the lock file nor insomnia.log takes a + /// record (LOCK_RECORD_MAX_BYTES and LOG_RECORD_MAX_BYTES are 0 here, + /// stand-ins for a write each refuses): no record can be written + /// anywhere. The restore still + /// runs, the run reports no result, keeps the journal as it was and + /// exits 1. This is the case no record covers (the app then resumes + /// nothing while session.json cannot be replaced; + /// JournaledSessionEndTests). + func testEndWhereNeitherFolderNorTheLockFileTakesTheRecordRecordsNothing() throws { + try writeLiveSession() + FileManager.default.createFile(atPath: fx.lock.path, contents: nil) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: fx.logFile.path, contents: nil) + let text = try ScriptFixture.replaceOnce(try String(contentsOf: fx.backstop, encoding: .utf8), "\nLOG_RECORD_MAX_BYTES=65536\n", with: "\nLOG_RECORD_MAX_BYTES=0\n") + try ScriptFixture.replaceOnce(text, "\nLOCK_RECORD_MAX_BYTES=1048576\n", with: "\nLOCK_RECORD_MAX_BYTES=0\n") + .write(to: fx.backstop, atomically: true, encoding: .utf8) + let journal = try Data(contentsOf: fx.state) + try fx.clearCalls() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: logs.path) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fx.home.path) + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: logs.path) + } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "the restore runs") + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + XCTAssertEqual(try recordsAside(), []) + XCTAssertEqual(try recordsInTheLogFolder(), []) + XCTAssertEqual(try Data(contentsOf: fx.lock), Data()) + XCTAssertEqual(try Data(contentsOf: fx.state), journal, "the journal is kept as it was") + let log = fx.log() + XCTAssertTrue(log.contains("a new file in \(fx.home.path) or \(logs.path), the recovery lock file \(fx.lock.path), or the log file \(fx.logFile.path). Sleep is restored anyway"), log) + XCTAssertTrue(log.contains("its supervisor reported no result within"), log) + } + + /// In the log folder as beside session.json: a record that matches no + /// session.json goes, and the live session is checked as usual. One + /// that cmp cannot read stays and ends nothing. A symlink, a FIFO and + /// other names are never opened or removed. + func testStaleRecordInTheLogFolderIsRemovedAndOthersAreLeft() throws { + try writeLiveSession() + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let stale = logs.appendingPathComponent("ended-session.json.Stale001") + try #"{"endsAt":"2001-01-01T00:00:00Z","startedAt":"2001-01-01T00:00:00Z"}"#.write(to: stale, atomically: true, encoding: .utf8) + let unreadable = logs.appendingPathComponent("ended-session.json.NoRead00") + try FileManager.default.copyItem(at: fx.session, to: unreadable) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + let copy = fx.root.appendingPathComponent("copy-of-session") + try FileManager.default.copyItem(at: fx.session, to: copy) + let link = logs.appendingPathComponent("ended-session.json.Link0000") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: copy) + let other = logs.appendingPathComponent("ended-session.json.Other0000") + try FileManager.default.copyItem(at: fx.session, to: other) + let fifo = try FIFOWatch(at: logs.appendingPathComponent("ended-session.json.Fifo0000")) + defer { fifo.stop() } + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertFalse(fifo.readerSeen, "a FIFO named like a record was opened") + XCTAssertFalse(fx.exists(stale)) + for kept in [unreadable, link, other, fifo.url] { + XCTAssertNotNil(try? FileManager.default.attributesOfItem(atPath: kept.path), kept.lastPathComponent) + } + } + + /// A log folder that is a symlink is not searched: a matching record + /// in the folder it points to ends nothing and stays. Nor is a record + /// written through it when the folder beside session.json takes no new + /// file: the record goes in the recovery lock file, as when both + /// folders refuse. + func testALogFolderThatIsASymlinkIsNeitherSearchedNorWrittenThrough() throws { + try writeLiveSession() + let elsewhere = fx.root.appendingPathComponent("elsewhere", isDirectory: true) + try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) + if fx.exists(logs) { + for name in try fx.contents(of: logs) { + try FileManager.default.moveItem(at: logs.appendingPathComponent(name), to: elsewhere.appendingPathComponent(name)) + } + try FileManager.default.removeItem(at: logs) + } + try FileManager.default.createSymbolicLink(at: logs, withDestinationURL: elsewhere) + let matching = elsewhere.appendingPathComponent("ended-session.json.Elsewher") + try FileManager.default.copyItem(at: fx.session, to: matching) + do { + let app = try fx.holdAliveLock() + defer { app.release() } + try assertSessionKept(try fx.run(fx.backstop)) + XCTAssertEqual(calls(), [batteryRead, thermalRead], "checked as usual") + XCTAssertTrue(fx.exists(matching)) + } + + try FileManager.default.removeItem(at: matching) + FileManager.default.createFile(atPath: fx.lock.path, contents: nil) + try fx.clearCalls() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fx.home.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fx.home.path) } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertTrue(fx.exists(fx.session)) + XCTAssertEqual(try fx.contents(of: elsewhere).filter { Paths.isEndedSessionAsideName($0) }, [], "no record through the symlink") + XCTAssertTrue(fx.log().contains("or a new file in \(fx.home.path) or \(logs.path); its end is recorded in the recovery lock file \(fx.lock.path) instead"), fx.log()) + XCTAssertEqual(try String(contentsOf: fx.lock, encoding: .utf8), "ended-session-v1 \(try Data(contentsOf: fx.session).base64EncodedString())\n") + } + + /// A record aside that matches no session.json goes, as a stale + /// ended-session.json does, and the live session is checked as usual. + /// One that cmp cannot read is not shown to be stale and stays; it ends + /// nothing. A symlink, a FIFO and other names are never opened or + /// removed. + func testStaleRecordAsideIsRemovedAndOthersAreLeft() throws { + try writeLiveSession() + let stale = fx.home.appendingPathComponent("ended-session.json.Stale001") + try #"{"endsAt":"2001-01-01T00:00:00Z","startedAt":"2001-01-01T00:00:00Z"}"#.write(to: stale, atomically: true, encoding: .utf8) + let unreadable = fx.home.appendingPathComponent("ended-session.json.NoRead00") + try FileManager.default.copyItem(at: fx.session, to: unreadable) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + let copy = fx.root.appendingPathComponent("copy-of-session") + try FileManager.default.copyItem(at: fx.session, to: copy) + let link = fx.home.appendingPathComponent("ended-session.json.Link0000") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: copy) + let other = fx.home.appendingPathComponent("ended-session.json.Other0000") + try FileManager.default.copyItem(at: fx.session, to: other) + let fifo = try FIFOWatch(at: fx.home.appendingPathComponent("ended-session.json.Fifo0000")) + defer { fifo.stop() } + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertFalse(fifo.readerSeen, "a FIFO named like a record was opened") + XCTAssertFalse(fx.exists(stale)) + for kept in [unreadable, link, other, fifo.url] { + XCTAssertNotNil(try? FileManager.default.attributesOfItem(atPath: kept.path), kept.lastPathComponent) + } + } + + /// A journaled end of another session.json (other bytes) ends nothing: + /// the live session is checked as usual and kept, and the record stays. + func testJournalRecordOfAnotherSessionDoesNotEndTheSession() throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + let other = Data(#"{"endsAt":"2001-01-01T00:00:00Z","startedAt":"2001-01-01T00:00:00Z"}"#.utf8).base64EncodedString() + let journal = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"endedSession":"\#(other)"}"# + try fx.writeState(journal) + let app = try fx.holdAliveLock() + defer { app.release() } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 0, r.stderr + fx.log()) + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertTrue(fx.exists(fx.session), "the session must stand") + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), journal, "journal must not be rewritten") + } + + /// endedSession is a string or absent, as the app decodes it. Any other + /// type makes the journal malformed for the agent and the app alike. + func testJournalWithAnEndRecordThatIsNotAStringIsMalformed() throws { + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + let broken = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"endedSession":42}"# + try fx.writeState(broken) + + let r = try fx.run(fx.backstop) + + XCTAssertNotEqual(r.status, 0) + XCTAssertEqual(fx.calls(), []) + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), broken) + XCTAssertTrue(fx.log().contains("endedSession is a integer, not a string"), fx.log()) + XCTAssertThrowsError(try Store(paths: Paths(root: fx.home)).loadState()) + } + + /// sessionCutoffs is a record the app writes as "30 false". A value it + /// does not write, of any type, leaves the journal usable for the agent + /// and both uninstall modes, as for the app, which reads it as none: + /// the undo runs, and the value stays as it is. Each run has a fixture + /// of its own, and they go several at a time. + func testJournalWithSessionCutoffsTheAppDoesNotWriteIsStillUsable() async throws { + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + var rows: [(value: String, purge: Bool?, f: ScriptFixture)] = [] + for value in [#""96 false""#, "30", "true", #"["30 false"]"#, #"{"endFloor":30}"#, "null", #""30 false""#] { + for purge in [nil, false, true] { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + if purge != nil { + try f.installMachinery() + } + try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + let journal = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false,"sessionCutoffs":\#(value)}"# + try f.writeState(journal) + XCTAssertEqual(try Store(paths: Paths(root: f.home)).loadState()?.sleepDisabledByUs, true, value) + rows.append((value, purge, f)) + } + } + + let results = try await ScriptFixture.runAll(rows.map { row in + row.purge.map { row.f.launch(row.f.uninstall, $0 ? ["--purge"] : []) } ?? row.f.launch(row.f.backstop) + }) + + for (row, r) in zip(rows, results) { + let (value, purge, f) = (row.value, row.purge, row.f) + let label = "\(value), \(purge.map { $0 ? "uninstall --purge" : "uninstall" } ?? "agent")" + XCTAssertEqual(r.status, 0, "\(label): \(r.stderr) \(r.stdout) \(f.log())") + XCTAssertTrue(f.calls().contains("sudo -n \(f.fakePmset) -a disablesleep 0"), "\(label): \(f.calls())") + XCTAssertFalse(f.log().contains("sessionCutoffs is a"), "\(label): \(f.log())") + if purge == nil { + let after = try String(contentsOf: f.state, encoding: .utf8) + XCTAssertTrue(after.contains(#""sessionCutoffs":\#(value)"#) || after.contains(#""sessionCutoffs" : \#(value)"#), "\(label): kept as it is: \(after)") + XCTAssertFalse(f.exists(f.session), label) + } + } + } + + /// Greptile 4219151895, uninstall.sh's side (PathSubstitutionTests has + /// the agent's): an uninstall that undoes a journaled sleep hold through + /// the checkout's backstop.sh, run on twin fixtures with the usual PATH + /// (the control) and with PathSubstitutes first in PATH, prints the + /// same, makes the same calls and removes the same files, and neither + /// script calls a stand-in. Its readers (session.json and the journal's + /// shape, the App Nap list, its uid and the app's folder names) would + /// read otherwise from a stand-in. + func testUninstallAndItsBackstopTakeNoToolFromPath() throws { + let twin = try ScriptFixture() + defer { twin.destroy() } + for f in [fx!, twin] { + try f.installMachinery() + try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + try f.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) + } + let log = twin.root.appendingPathComponent("substitutes.log") + let path = try PathSubstitutes.path(in: twin.root.appendingPathComponent("substitutes", isDirectory: true), log: log, + scripts: [twin.repoScripts.path + "/", twin.app.path + "/"]) + + let control = try fx.run(fx.uninstall) + let r = try twin.run(twin.uninstall, extraEnvironment: ["PATH": path]) + + XCTAssertEqual(PathSubstitutes.calls(in: log), [], "uninstall.sh or its backstop.sh called a tool from PATH") + XCTAssertEqual(control.status, 0, control.stderr + control.stdout) + XCTAssertEqual(r.status, 0, r.stderr + r.stdout) + let same = { (text: String, f: ScriptFixture) in text.replacingOccurrences(of: f.root.path, with: "") } + XCTAssertEqual(same(r.stdout, twin), same(control.stdout, fx)) + XCTAssertEqual(same(r.stderr, twin), same(control.stderr, fx)) + XCTAssertEqual(twin.calls().map { same($0, twin) }, fx.calls().map { same($0, fx) }) + XCTAssertTrue(fx.calls().contains("sudo -n \(fx.fakePmset) -a disablesleep 0"), fx.calls().joined(separator: "\n")) + for f in [fx!, twin] { + XCTAssertFalse(f.exists(f.session)) + XCTAssertFalse(f.exists(f.app)) + } + } + + /// A record left from an earlier session.json matches nothing: it goes, + /// and the live session is checked as usual. + func testStaleEndRecordIsRemovedAndDoesNotEndTheSession() throws { + try writeLiveSession() + try #"{"endsAt":"2001-01-01T00:00:00Z","startedAt":"2001-01-01T00:00:00Z"}"#.write(to: fx.endedSession, atomically: true, encoding: .utf8) + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertFalse(fx.exists(fx.endedSession)) + } + + /// An exact record beside a session.json that cannot be read (mode 0, + /// immutable, so it cannot be moved aside either) is not shown to be + /// stale: cmp cannot compare them (exit 2), so it stays, on every + /// retry. Once session.json can be read again it ends that session: + /// the next run, with the app alive, ends it without the checks and + /// removes both. A record beside a session.json that is not a regular + /// file stays too; one beside no session.json goes. + func testAnExactEndRecordStaysWhileSessionJSONCannotBeCompared() throws { + try writeLiveSession() + try FileManager.default.copyItem(at: fx.session, to: fx.endedSession) + let bytes = try Data(contentsOf: fx.session) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: fx.session.path) + try setImmutable(fx.session, true) + defer { + try? setImmutable(fx.session, false) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fx.session.path) + } + let app = try fx.holdAliveLock() + defer { app.release() } + + let r = try fx.run(fx.backstop) + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertEqual(try Data(contentsOf: fx.endedSession), bytes, "kept while it cannot be compared") + let retry = try fx.run(fx.backstop) + XCTAssertEqual(retry.status, 1, retry.stderr + fx.log()) + XCTAssertEqual(try Data(contentsOf: fx.endedSession), bytes, "kept on the retry too") + + try setImmutable(fx.session, false) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fx.session.path) + try fx.writeState(liveJournal) + try fx.clearCalls() + let repaired = try fx.run(fx.backstop) + XCTAssertEqual(repaired.status, 0, repaired.stderr + fx.log()) + XCTAssertTrue(fx.log().contains("already ended (recorded in \(fx.endedSession.path))"), fx.log()) + XCTAssertEqual(calls(), [sleepRestored], "no checks for a session recorded as ended") + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + + try FileManager.default.createDirectory(at: fx.session, withIntermediateDirectories: false) + try bytes.write(to: fx.endedSession) + _ = try fx.run(fx.backstop) + XCTAssertEqual(try Data(contentsOf: fx.endedSession), bytes, "not shown to be stale beside a session.json that is not a file") + // That run moved the directory aside, as any session.json it cannot + // read; nothing is at session.json now. + try? FileManager.default.removeItem(at: fx.session) + _ = try fx.run(fx.backstop) + XCTAssertFalse(fx.exists(fx.endedSession), "stale once session.json is gone") + } + + /// A stale record that cannot be removed ends nothing, but it is a copy + /// of a session's times, so every run says it is still there. + func testStaleEndRecordThatCannotBeRemovedIsLogged() throws { + try writeLiveSession() + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertTrue(fx.exists(fx.endedSession)) + XCTAssertTrue(fx.log().contains("could not remove \(fx.endedSession.path)"), fx.log()) + } + + /// A FIFO at ended-session.json is never opened: cmp would block on it + /// while the run holds the recovery lock, and then neither this script + /// nor the app could ever end the session. It is not a record, so it + /// matches nothing and goes, and the session is judged as usual: with no + /// app alive it ends and sleep is restored. + func testEndRecordThatIsAFIFOIsNeverOpenedAndTheSessionStillEnds() throws { + try writeLiveSession() + let fifo = try FIFOWatch(at: fx.endedSession) + defer { fifo.stop() } + + let r = try fx.run(fx.backstop) + + XCTAssertFalse(fifo.readerSeen, "ended-session.json was opened although it is a FIFO") + try assertSessionEnded(r, reason: "Insomnia is not running") + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + } + + /// The same FIFO with the app alive: the session stands, the reads run, + /// and the FIFO goes without being opened. + func testEndRecordThatIsAFIFODoesNotEndALiveAppsSession() throws { + try writeLiveSession() + let fifo = try FIFOWatch(at: fx.endedSession) + defer { fifo.stop() } + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertFalse(fifo.readerSeen, "ended-session.json was opened although it is a FIFO") + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + XCTAssertFalse(fx.exists(fx.endedSession)) + } + + /// A FIFO there that cannot be removed either, with session.json that + /// cannot be removed: recording the end compares and replaces, and + /// neither opens the FIFO. Sleep is restored and the run exits 1. + func testEndRecordFIFOThatCannotBeReplacedIsNeverOpenedWhenRecordingTheEnd() throws { + try writeLiveSession() + try setImmutable(fx.session, true) + let fifo = try FIFOWatch(at: fx.endedSession) + defer { fifo.stop() } + try setImmutable(fx.endedSession, true) + defer { try? setImmutable(fx.endedSession, false) } + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 1, r.stderr + fx.log()) + XCTAssertFalse(fifo.readerSeen, "ended-session.json was opened although it is a FIFO") + XCTAssertTrue(fifo.isStillFIFO) + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertTrue(fx.log().contains("could not remove \(fx.session.path) or record its end"), fx.log()) + } + + /// config.json is read only as a regular file, like session.json and + /// state.json. A FIFO there reads as a missing file: the defaults apply + /// (10% floor, thermal rules on), so both reads run and the session + /// stands. plutil on macOS 26 refuses a FIFO by itself, so this pins + /// the outcome; the regular-file check does not rely on that. + func testConfigThatIsAFIFOIsNeverOpenedAndTheDefaultsApply() throws { + try writeLiveSession() + let fifo = try FIFOWatch(at: fx.config) + defer { fifo.stop() } + let app = try fx.holdAliveLock() + defer { app.release() } + + try assertSessionKept(try fx.run(fx.backstop)) + + XCTAssertFalse(fifo.readerSeen, "config.json was opened although it is a FIFO") + XCTAssertTrue(fifo.isStillFIFO) + XCTAssertEqual(calls(), [batteryRead, thermalRead]) + } + + /// A config.json this user cannot read gives the app no settings + /// either (it moves the file aside), so the defaults apply, 10% and + /// thermal rules on, whatever the file holds. + func testConfigThatCannotBeReadGivesTheDefaults() throws { + try writeLiveSession() + try fx.writeConfig(#"{"endFloor": 0, "thermalRules": false}"#) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: fx.config.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fx.config.path) } + let app = try fx.holdAliveLock() + defer { app.release() } + fx.setBattery(fx.battery(source: "Battery Power", percent: 9)) + + try assertSessionEnded(try fx.run(fx.backstop), reason: "battery at 9% on battery power, below the 10% end floor") + XCTAssertFalse(fx.log().contains("enforcing the strictest"), fx.log()) + } + + /// The log is appended to only as a regular file. Most lines are written + /// under the recovery lock, and open(2) for writing on a FIFO with no + /// reader blocks. The test holds a read end open, so a write lands in + /// the FIFO's buffer instead of hanging the run, and the buffer must + /// stay empty. The lines are dropped; the session still ends. + func testLogThatIsAFIFOIsNeverWrittenAndTheSessionStillEnds() throws { + try writeLiveSession() + try FileManager.default.createDirectory(at: fx.logFile.deletingLastPathComponent(), withIntermediateDirectories: true) + XCTAssertEqual(mkfifo(fx.logFile.path, 0o600), 0) + let reader = open(fx.logFile.path, O_RDONLY | O_NONBLOCK) + XCTAssertGreaterThanOrEqual(reader, 0) + defer { close(reader) } + + let r = try fx.run(fx.backstop) + + var buffer = [UInt8](repeating: 0, count: 4096) + let n = read(reader, &buffer, buffer.count) + XCTAssertLessThanOrEqual(n, 0, "the log FIFO was written: \(String(decoding: buffer.prefix(max(n, 0)), as: UTF8.self))") + XCTAssertEqual(r.status, 0, r.stderr) + XCTAssertEqual(calls(), [sleepRestored]) + XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, false) + XCTAssertFalse(fx.exists(fx.session)) + var info = stat() + XCTAssertTrue(lstat(fx.logFile.path, &info) == 0 && info.st_mode & S_IFMT == S_IFIFO) + } + + /// The relaunch the record exists for. The backstop ends the session of + /// an app that died and cannot remove session.json. Insomnia launched + /// afterwards finds a valid session.json, sees the record and restores + /// instead of resuming. Once the file can be removed, its next reconcile + /// removes both. + @MainActor + func testAppRelaunchedAfterAnEndRecordRestoresInsteadOfResuming() async throws { + // Written by the app's Store, so the app reads it as a valid session. + try Store(paths: Paths(root: fx.home)).saveSession(Session(startedAt: Date(timeIntervalSinceNow: -600), endsAt: Date(timeIntervalSinceNow: 3600))) + try fx.writeState(journalWithSavedBrightness) + try setImmutable(fx.session, true) + XCTAssertEqual(try fx.run(fx.backstop).status, 1, fx.log()) + XCTAssertTrue(fx.exists(fx.endedSession)) + + let restoreHome = pointInsomniaHome(at: fx.home) + defer { restoreHome() } + let paths = Paths.fromEnvironment() + let sleepGuard = FakeSleepGuard() + let display = FakeDisplayDimmer(brightness: 0) + let notifier = RecordingNotifier() + let m = SessionManager( + paths: paths, + sleepGuard: sleepGuard, + processControl: FakeProcessControl(), + backstop: FakeBackstop(), + display: display, + notifier: notifier, + clamshell: { false }, + recoveryLockTimeout: 2, + recoveryRetryDelay: 3600, + reassertDelay: .seconds(3600) + ) + await m.reconcile() + + XCTAssertNil(m.session, "the ended session must not come back") + XCTAssertFalse(sleepGuard.calls.contains("disablesleep 1"), "\(sleepGuard.calls)") + XCTAssertEqual(display.sets.last, 0.6) + XCTAssertFalse(try XCTUnwrap(try Store(paths: paths).loadState()).isDirty) + XCTAssertTrue(notifier.posts.contains { $0.body.contains("its end is recorded, so a relaunch will not resume it") }, "\(notifier.posts)") + + try setImmutable(fx.session, false) + await m.reconcile() + + XCTAssertFalse(fx.exists(fx.session)) + XCTAssertFalse(fx.exists(fx.endedSession)) + XCTAssertFalse(sleepGuard.calls.contains("disablesleep 1"), "\(sleepGuard.calls)") + } + func testExpiredSessionRestoresEverythingAndClearsJournal() throws { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) let started = 1_789_388_423 @@ -523,7 +2176,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.log().contains("journal cleared"), fx.log()) fx.setMode("defaults", "ok") - fx.clearCalls() + try fx.clearCalls() let after = try fx.run(fx.backstop) XCTAssertEqual(after.status, 0, after.stderr + fx.log()) XCTAssertEqual(fx.calls(), ["defaults delete com.google.Chrome NSAppSleepDisabled"]) @@ -558,6 +2211,51 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(fx.log().contains("malformed"), fx.log()) } + /// Frozen entries the app decodes without an identity, because a + /// startedAt or startedAtMicros is missing or null (FrozenProcess), + /// with what follows of the wrong type or past Int32: the app loads the + /// journal, so the run restores sleep, signals nothing and keeps each + /// entry for the app. The same values in an entry with every identity + /// key are read by the app and refused, so that journal stops the run + /// before any command, its bytes and session.json kept. Each entry runs + /// on a fixture of its own, several at a time. + func testProvisionalEntriesTheAppDoesNotReadFurtherDoNotBlockRecovery() async throws { + let rows: [(entry: String, loads: Bool)] = [ + (#"{"pid":4242,"startedAtMicros":"bad","bootSession":"test"}"#, true), + (#"{"pid":4242,"startedAt":123,"bootSession":42}"#, true), + (#"{"pid":4242,"startedAtMicros":2147483648}"#, true), + (#"{"pid":4242,"startedAt":null,"startedAtMicros":"bad","bootSession":42}"#, true), + (#"{"pid":4242,"startedAt":123,"startedAtMicros":0,"bootSession":42}"#, false), + (#"{"pid":4242,"startedAt":123,"startedAtMicros":2147483648,"bootSession":"test"}"#, false), + ] + let journalOf = { (row: (entry: String, loads: Bool)) in #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"dockerFrozen":false,"frozenProcesses":["# + row.entry + "]}" } + var sessions: [Data] = [] + let ran = try await runBackstopRows(rows, alive: { _ in false }) { row, f in + try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + try f.writeState(journalOf(row)) + sessions.append(try Data(contentsOf: f.session)) + } + defer { ran.forEach { $0.f.destroy() } } + for ((row, f, r), session) in zip(ran, sessions) { + let journal = journalOf(row) + let calls = f.calls().map { $0.replacingOccurrences(of: f.fakePmset, with: "PMSET") } + XCTAssertNotEqual(r.status, 0, row.entry) + XCTAssertFalse(calls.contains { $0.hasPrefix("kill") || $0.contains("-CONT") }, "\(row.entry): \(calls)") + if row.loads { + XCTAssertEqual(calls, [sleepRestored], row.entry) + XCTAssertTrue(f.log().contains("pid 4242 was journaled without identity; not signaled, kept for the app to resolve"), "\(row.entry): \(f.log())") + XCTAssertFalse(f.log().contains("malformed"), "\(row.entry): \(f.log())") + XCTAssertEqual((try f.stateJSON()["frozenProcesses"] as? [Any])?.count, 1, row.entry) + XCTAssertEqual(try f.stateJSON()["sleepDisabledByUs"] as? Bool, false, row.entry) + } else { + XCTAssertEqual(calls, [], row.entry) + XCTAssertTrue(f.log().contains("malformed"), "\(row.entry): \(f.log())") + XCTAssertEqual(try String(contentsOf: f.state, encoding: .utf8), journal, row.entry) + XCTAssertEqual(try Data(contentsOf: f.session), session, row.entry) + } + } + } + func testExpiredSessionWithMissingJournalRunsNothingPrivileged() throws { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) @@ -745,15 +2443,18 @@ final class RecoveryScriptTests: XCTestCase { } /// The fixture's own session, which has every field the decoder needs, - /// is a session: a future one keeps sleep disabled and nothing runs. + /// is a session: a future one, with the app holding the alive lock, + /// keeps sleep disabled, and only the battery and thermal reads run. func testCompleteSessionWithAFutureEndsAtIsValid() throws { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) + let app = try fx.holdAliveLock() + defer { app.release() } let r = try fx.run(fx.backstop) XCTAssertEqual(r.status, 0, r.stderr + fx.log()) - XCTAssertEqual(fx.calls(), []) + XCTAssertEqual(calls(), [batteryRead, thermalRead], "reads only, nothing privileged") XCTAssertTrue(fx.exists(fx.session)) XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true) } @@ -845,8 +2546,9 @@ final class RecoveryScriptTests: XCTestCase { } /// A session written with offsets is a session for the backstop too: a - /// future one keeps sleep disabled and nothing runs, a past one is - /// undone like any expired session. + /// future one with the app running keeps sleep disabled, and only the + /// battery and thermal reads run. A past one is undone like any expired + /// session. func testSessionWithOffsetDatesIsReadLikeTheApp() throws { let dirty = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"# let f = DateFormatter() @@ -861,13 +2563,16 @@ final class RecoveryScriptTests: XCTestCase { try write(endsAt: Date(timeIntervalSinceNow: 3600)) try fx.writeState(dirty) + let app = try fx.holdAliveLock() let future = try fx.run(fx.backstop) + app.release() XCTAssertEqual(future.status, 0, future.stderr + fx.log()) - XCTAssertEqual(fx.calls(), []) + XCTAssertEqual(calls(), [batteryRead, thermalRead]) XCTAssertTrue(fx.exists(fx.session)) XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), dirty) + try fx.clearCalls() try write(endsAt: Date(timeIntervalSinceNow: -60)) let past = try fx.run(fx.backstop) @@ -903,7 +2608,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(fx.log().contains("session.json cannot be read (it is not a regular file, so it is not opened); moved to \(copy.path)"), fx.log()) // The next run finds no session and nothing to undo. - fx.clearCalls() + try fx.clearCalls() let again = try fx.run(fx.backstop) XCTAssertEqual(again.status, 0, again.stderr + fx.log()) XCTAssertEqual(fx.calls(), []) @@ -1069,8 +2774,9 @@ final class RecoveryScriptTests: XCTestCase { func testUninstallNeverOpensSessionOrJournalFIFOs() throws { try fx.installMachinery() let session = try FIFOWatch(at: fx.session) + defer { session.stop() } let state = try FIFOWatch(at: fx.state) - defer { session.stop(); state.stop() } + defer { state.stop() } let r = try fx.run(fx.uninstall) @@ -1123,6 +2829,240 @@ final class RecoveryScriptTests: XCTestCase { } } + /// Records aside (ended-session.json.<8 letters or digits>) go with or + /// without --purge. The backstop run removes a stale one first; one it + /// cannot remove (immutable here) is named and counted by uninstall, + /// a directory of that name is left and named, other names stay. + func testUninstallRemovesRecordsAsideAndNamesWhatItCannot() throws { + for purge in [false, true] { + try fx.installMachinery() + let removable = fx.home.appendingPathComponent("ended-session.json.Abcd1234") + let pinned = fx.home.appendingPathComponent("ended-session.json.Pinned00") + let dir = fx.home.appendingPathComponent("ended-session.json.Dir00000") + let notOurs = fx.home.appendingPathComponent("ended-session.json.notes") + for file in [removable, pinned, notOurs] { + try "{}".write(to: file, atomically: true, encoding: .utf8) + } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try setImmutable(pinned, true) + defer { try? setImmutable(pinned, false) } + + let r = try fx.run(fx.uninstall, purge ? ["--purge"] : []) + + XCTAssertEqual(r.status, 1, "purge \(purge): " + r.stderr + r.stdout) + XCTAssertFalse(fx.exists(removable), "purge \(purge)") + XCTAssertTrue(fx.exists(pinned)) + XCTAssertTrue(r.stderr.contains("Could not remove \(pinned.path); left in place."), r.stderr) + XCTAssertTrue(r.stdout.contains("Left \(dir.path): it is not a regular file, so Insomnia did not write it."), r.stdout) + XCTAssertTrue(fx.exists(dir)) + XCTAssertTrue(fx.exists(notOurs)) + try setImmutable(pinned, false) + try FileManager.default.removeItem(at: pinned) + try FileManager.default.removeItem(at: dir) + try FileManager.default.removeItem(at: notOurs) + } + } + + /// The record of a session's end in the recovery lock file goes with or + /// without --purge, emptied in place: the file and its inode stay. The + /// fixture's backstop is changed here to leave it, so what empties it + /// is uninstall's own copy of the rule. A symlink at the lock path + /// before uninstall starts stops it before it removes anything, as on + /// main: the backstop cannot show that the link is the lock uninstall + /// holds, so it waits on that lock and gives up (exit 75). One put there + /// during the run is named and left. Neither writes the file a symlink + /// points to. + func testUninstallEmptiesTheLockFileRecordInPlaceInBothModes() throws { + let text = try String(contentsOf: fx.backstop, encoding: .utf8) + let clear = #" if lock_is_held_file && { : > "$LOCK"; } 2>/dev/null; then return 0; fi"# + try ScriptFixture.replaceOnce(text, clear, with: " return 0") + .write(to: fx.backstop, atomically: true, encoding: .utf8) + let record = Data("ended-session-v1 QUJD\n".utf8) + for purge in [false, true] { + try fx.installMachinery() + FileManager.default.createFile(atPath: fx.lock.path, contents: record) + let lockInode = try fx.inode(fx.lock) + + let r = try fx.run(fx.uninstall, purge ? ["--purge"] : []) + + XCTAssertEqual(r.status, 0, "purge \(purge): " + r.stderr + r.stdout) + XCTAssertEqual(try Data(contentsOf: fx.lock), Data(), "purge \(purge)") + XCTAssertEqual(try fx.inode(fx.lock), lockInode, "purge \(purge)") + XCTAssertTrue(r.stdout.contains("Emptied \(fx.lock.path) of the record of a session's end; the file itself is kept."), r.stdout) + } + + try fx.installMachinery() + let target = fx.root.appendingPathComponent("elsewhere.lock") + try record.write(to: target) + try FileManager.default.removeItem(at: fx.lock) + try FileManager.default.createSymbolicLink(at: fx.lock, withDestinationURL: target) + var r = try fx.run(fx.uninstall) + XCTAssertEqual(r.status, 1, r.stderr + r.stdout) + XCTAssertTrue(r.stderr.contains("Uninstall stopped BEFORE removing anything"), r.stderr) + XCTAssertTrue(r.stderr.contains("backstop exited 75"), r.stderr) + XCTAssertTrue((try? String(contentsOf: fx.logFile, encoding: .utf8))?.contains("recovery lock \(fx.lock.path) still held") == true) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: fx.lock.path), target.path) + XCTAssertEqual(try Data(contentsOf: target), record) + + // That uninstall removed nothing, so the machinery is still there. + try FileManager.default.removeItem(at: fx.lock) + FileManager.default.createFile(atPath: fx.lock.path, contents: record) + let moved = fx.root.appendingPathComponent("moved.lock") + try ScriptFixture.replaceOnce(text, clear, with: " /bin/mv \"$LOCK\" '\(moved.path)' && /bin/ln -s '\(target.path)' \"$LOCK\"; return 0") + .write(to: fx.backstop, atomically: true, encoding: .utf8) + r = try fx.run(fx.uninstall) + XCTAssertEqual(r.status, 0, r.stderr + r.stdout) + XCTAssertTrue(r.stdout.contains("Left the contents of \(fx.lock.path): it is not a regular file this user owns."), r.stdout) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: fx.lock.path), target.path) + XCTAssertEqual(try Data(contentsOf: target), record) + XCTAssertEqual(try Data(contentsOf: moved), record) + } + + /// Records in the log folder go with or without --purge, as those + /// beside session.json do: one uninstall cannot remove (immutable) is + /// named by uninstall itself, and other names stay. With only a record + /// in it, --purge then removes the log folder. + func testUninstallRemovesRecordsInTheLogFolderInBothModes() throws { + for purge in [false, true] { + try fx.installMachinery() + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let removable = logs.appendingPathComponent("ended-session.json.Abcd1234") + let pinned = logs.appendingPathComponent("ended-session.json.Pinned00") + let notOurs = logs.appendingPathComponent("ended-session.json.notes") + for file in [removable, pinned, notOurs] { + try "{}".write(to: file, atomically: true, encoding: .utf8) + } + try setImmutable(pinned, true) + defer { try? setImmutable(pinned, false) } + + let r = try fx.run(fx.uninstall, purge ? ["--purge"] : []) + + XCTAssertEqual(r.status, 1, "purge \(purge): " + r.stderr + r.stdout) + XCTAssertFalse(fx.exists(removable), "purge \(purge)") + XCTAssertTrue(fx.exists(pinned)) + XCTAssertTrue(r.stderr.contains("Could not remove \(pinned.path); left in place."), r.stderr) + XCTAssertTrue(fx.exists(notOurs)) + try setImmutable(pinned, false) + try FileManager.default.removeItem(at: pinned) + try FileManager.default.removeItem(at: notOurs) + } + + try fx.installMachinery() + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let only = logs.appendingPathComponent("ended-session.json.Only0000") + try "{}".write(to: only, atomically: true, encoding: .utf8) + let r = try fx.run(fx.uninstall, ["--purge"]) + XCTAssertEqual(r.status, 0, r.stderr + r.stdout) + XCTAssertFalse(fx.exists(only)) + XCTAssertFalse(fx.exists(logs), "--purge removes the log folder once the record is gone") + } + + /// --purge keeps insomnia.log and insomnia.log.1 while session.json is + /// still there, as they may record its end (record_end_in_log in + /// backstop.sh); it removes them once session.json is gone. Purge runs + /// only with no session.json (journal_problems), so this copy of + /// uninstall.sh puts a pinned one back once purge starts: the guard is + /// defensive, and this is the only way to reach it. + func testUninstallPurgeKeepsTheLogsWhileSessionJSONIsThere() throws { + try fx.installMachinery() + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let rotated = logs.appendingPathComponent("insomnia.log.1") + for file in [fx.logFile, rotated] { + try "insomnia-ended-session-v1 2 e30=\n".write(to: file, atomically: true, encoding: .utf8) + } + let step = " step \"Purging Insomnia's files in $APP_SUPPORT and $LOG_DIR\"\n" + var text = try String(contentsOf: fx.uninstall, encoding: .utf8) + XCTAssertEqual(text.components(separatedBy: step).count, 2) + text = text.replacingOccurrences(of: step, with: step + " printf '{}' > \"$SESSION\"; /usr/bin/chflags uchg \"$SESSION\"\n") + try text.write(to: fx.uninstall, atomically: true, encoding: .utf8) + defer { try? setImmutable(fx.session, false) } + + let r = try fx.run(fx.uninstall, ["--purge"]) + + XCTAssertEqual(r.status, 1, r.stderr + r.stdout) + XCTAssertTrue(r.stderr.contains("Could not remove \(fx.session.path); left in place."), r.stderr) + XCTAssertTrue(r.stdout.contains("Kept \(logs.path)/insomnia.log and \(logs.path)/insomnia.log.1: \(fx.session.path) is still there, and they may record its end."), r.stdout) + XCTAssertTrue(fx.exists(fx.logFile)) + XCTAssertTrue(fx.exists(rotated)) + + try setImmutable(fx.session, false) + try FileManager.default.removeItem(at: fx.session) + text = text.replacingOccurrences(of: " printf '{}' > \"$SESSION\"; /usr/bin/chflags uchg \"$SESSION\"\n", with: "") + try text.write(to: fx.uninstall, atomically: true, encoding: .utf8) + try fx.installMachinery() + let again = try fx.run(fx.uninstall, ["--purge"]) + XCTAssertEqual(again.status, 0, again.stderr + again.stdout) + XCTAssertFalse(fx.exists(fx.logFile)) + XCTAssertFalse(fx.exists(rotated)) + } + + /// The backstop run uninstall makes (--force), in both modes, with a + /// log that ends in a line cut short. session.json, ended-session.json + /// and state.json are pinned, no record aside can be created and the + /// lock file takes none, so the log is the only place left for the + /// record. A record of this session whose newline alone is missing + /// ends it, and every line the run writes after it starts on a line of + /// its own, so it stays whole and is used again. Otherwise the log ends + /// in a line a write left partway, and another write leaves `cut sh` + /// at its end again before each check for a record (GREP is a fake + /// here), the one just before the record is written included: the + /// run's first attempt writes the record on a line of its own and + /// reads it back. Uninstall then stops with session.json and the logs + /// kept. + func testUninstallsBackstopRunKeepsALineCutShortApartInBothModes() throws { + for purge in [false, true] { + for recorded in [true, false] { + let f = try ScriptFixture() + defer { + for file in [f.session, f.endedSession, f.state] { try? setImmutable(file, false) } + f.destroy() + } + try f.installMachinery() + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try f.writeState(liveJournal) + let record = String(decoding: try XCTUnwrap(LogEndRecord.line(for: try Data(contentsOf: f.session))), as: UTF8.self) + try Data("log\n\(recorded ? record : "cut sh")".utf8).write(to: f.logFile) + try "{}".write(to: f.endedSession, atomically: true, encoding: .utf8) + for file in [f.session, f.endedSession, f.state] { try setImmutable(file, true) } + var text = try ScriptFixture.replaceOnce(try String(contentsOf: f.backstop, encoding: .utf8), "MKTEMP=/usr/bin/mktemp", with: "MKTEMP=/usr/bin/false") + text = try ScriptFixture.replaceOnce(text, "\nLOCK_RECORD_MAX_BYTES=1048576\n", with: "\nLOCK_RECORD_MAX_BYTES=0\n") + if !recorded { + let grep = f.root.appendingPathComponent("grep") + try #""" + #!/bin/bash + if [[ "${1:-}" == -Fxq && "${2:-}" == -e && "${3:-}" == "insomnia-ended-session-v1 "* ]]; then + if [[ "$(/usr/bin/tail -c 1 '\#(f.logFile.path)'; printf x)" == $'\nx' ]]; then printf 'cut sh' >> '\#(f.logFile.path)'; fi + fi + exec /usr/bin/grep "$@" + + """#.write(to: grep, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: grep.path) + text = try ScriptFixture.replaceOnce(text, "\nGREP=/usr/bin/grep\n", with: "\nGREP='\(grep.path)'\n") + } + try text.write(to: f.backstop, atomically: true, encoding: .utf8) + + let r = try f.run(f.uninstall, purge ? ["--purge"] : []) + + let label = "\(purge ? "--purge" : "plain"), \(recorded ? "record" : "cut sh")" + let log = f.log() + XCTAssertNotEqual(r.status, 0, "\(label): \(r.stdout)") + XCTAssertTrue(f.calls().contains("sudo -n \(f.fakePmset) -a disablesleep 0"), "\(label): \(f.calls())") + XCTAssertEqual(log.components(separatedBy: "\n").filter { $0 == record }.count, 1, "\(label): \(log)") + XCTAssertTrue(log.contains("\(record)\n"), "\(label): \(log)") + XCTAssertTrue(log.contains("its end is recorded in the log file \(f.logFile.path) instead"), "\(label): \(log)") + if recorded { + XCTAssertTrue(log.hasPrefix("log\n\(record)\n"), "\(label): \(log)") + } else { + XCTAssertTrue(log.hasPrefix("log\ncut sh\n"), "\(label): \(log)") + XCTAssertTrue(log.contains("cut sh\n\(record)\n"), "\(label): \(log)") + XCTAssertEqual(Set(log.components(separatedBy: "\n").filter { $0.contains("cut sh") }), ["cut sh"], "\(label): \(log)") + } + XCTAssertTrue(f.exists(f.session), label) + XCTAssertTrue(f.exists(f.logFile), label) + } + } + } + /// --purge removes the moved-aside copies, but only names of exactly the /// shape Insomnia produces. Anything else under the prefix stays. func testUninstallPurgeRemovesOnlyMovedAsideSessionFilesOfInsomniasShape() throws { @@ -1139,6 +3079,38 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(try movedAsideSessions(), [notOurs]) } + /// The app renames a config.json it cannot decode to the same shape + /// (Store.moveAsideUnreadableConfig). A plain uninstall keeps those + /// copies, as it keeps config.json; --purge removes them, and only them: + /// another name under the prefix and a directory named like a copy stay. + func testUninstallKeepsMovedAsideConfigCopiesAndPurgeRemovesOnlyThose() throws { + func movedAsideConfigs() throws -> [String] { + try fx.contents(of: fx.home).filter { $0.hasPrefix("config.json.unreadable-") }.sorted() + } + try fx.installMachinery() + let ours = ["config.json.unreadable-20260101T000000Z", "config.json.unreadable-20260101T000000Z-2"] + let notOurs = "config.json.unreadable-notes.txt" + for name in ours + [notOurs] { + try "x".write(to: fx.home.appendingPathComponent(name), atomically: true, encoding: .utf8) + } + let dir = fx.home.appendingPathComponent("config.json.unreadable-20260101T000000Z-1") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let kept = try fx.run(fx.uninstall) + + XCTAssertEqual(kept.status, 0, kept.stderr + kept.stdout) + XCTAssertEqual(try movedAsideConfigs().count, 4) + XCTAssertTrue(kept.stdout.contains("Kept 2 unreadable config.json file(s) moved aside"), kept.stdout) + XCTAssertTrue(kept.stdout.contains("Kept \(dir.path): it is named like a moved-aside config.json but is not a regular file"), kept.stdout) + + try fx.installMachinery() + let purged = try fx.run(fx.uninstall, ["--purge"]) + + XCTAssertEqual(purged.status, 0, purged.stderr + purged.stdout) + XCTAssertEqual(try movedAsideConfigs(), [dir.lastPathComponent, notOurs].sorted()) + XCTAssertTrue(purged.stdout.contains("Left \(dir.path): it is named like a moved-aside config.json but is not a regular file"), purged.stdout) + } + /// Something that is not a regular file but has a moved-aside name (here /// a directory with a file in it) is not Insomnia's. --purge says so, /// leaves it with its contents, and still finishes: the copies beside it @@ -1226,7 +3198,7 @@ final class RecoveryScriptTests: XCTestCase { try fx.writeState(dirty) let holder = try fx.holdLock() - defer { holder.stop() } + defer { XCTAssertEqual(holder.stop(), 0, "the lock holder, reaped once its cat read the end of its input: \(holder.problems)") } let r = try fx.run(fx.backstop) @@ -1242,7 +3214,7 @@ final class RecoveryScriptTests: XCTestCase { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) XCTAssertEqual(try fx.run(fx.backstop).status, 0) - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.backstop) @@ -1805,10 +3777,12 @@ final class RecoveryScriptTests: XCTestCase { } try fx.writeMarkerBackstop(at: fx.legacyBackstop, name: "legacy") + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + let r = try fx.run(fx.uninstall, ["--purge"]) XCTAssertEqual(r.status, 0, r.stderr + r.stdout) - for gone in [fx.state, fx.config, fx.logFile, fx.home.appendingPathComponent("Logs/handoffs.log"), + for gone in [fx.state, fx.config, fx.endedSession, fx.logFile, fx.home.appendingPathComponent("Logs/handoffs.log"), fx.home.appendingPathComponent("Logs/insomnia.log.1"), fx.home.appendingPathComponent("Logs/handoffs.log.1"), fx.installedBackstop, fx.legacyBackstop, fx.plist, fx.app, fx.sudoers] { XCTAssertFalse(fx.exists(gone), gone.path) @@ -1821,6 +3795,25 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(fx.exists(fx.bin), "nothing outside the Insomnia tree is deleted") } + /// An end record that cannot be removed survives the purge, and the + /// purge names it and fails, as for any file it owns, instead of + /// reporting everything gone. The rest is still removed. + func testUninstallPurgeReportsAnEndRecordItCannotRemove() throws { + try fx.installMachinery() + try fx.writeState(#"{"sleepDisabledByUs":false,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) + try "{}".write(to: fx.endedSession, atomically: true, encoding: .utf8) + try setImmutable(fx.endedSession, true) + + let r = try fx.run(fx.uninstall, ["--purge"]) + + XCTAssertEqual(r.status, 1, r.stderr + r.stdout) + XCTAssertTrue(fx.exists(fx.endedSession)) + XCTAssertTrue(r.stderr.contains("Could not remove \(fx.endedSession.path); left in place."), r.stderr) + XCTAssertTrue(r.stderr.contains("Done, except 1 file(s) that could not be removed"), r.stderr) + XCTAssertFalse(fx.exists(fx.state)) + XCTAssertFalse(fx.exists(fx.config)) + } + func testUninstallPurgeNeverDeletesFilesItDidNotCreate() throws { // INSOMNIA_HOME pointing at a directory that also holds other things: // the exact owned files go, everything else and the directories stay. @@ -1994,6 +3987,71 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.app)) } + /// The folder uninstall.sh takes a checkout's backstop.sh from is found + /// without PATH or CDPATH (script_dir). Run by a relative path from the + /// checkout, with a dirname and a cat first in PATH that both print a + /// decoy checkout's scripts folder to uninstall.sh, and with CDPATH + /// holding that decoy, it runs the checkout's own backstop.sh; it calls + /// neither stand-in. The + /// control is the same copy with the folder found the way it was + /// before (cd "$(dirname ...)"): the stand-in dirname then picks the + /// decoy, whose backstop.sh runs. + func testUninstallFindsItsCheckoutWithoutPATHOrCDPATH() throws { + let found = "SCRIPT_DIR=\"$(script_dir)\"\n" + let original = try String(contentsOf: fx.uninstall, encoding: .utf8) + XCTAssertEqual(original.components(separatedBy: found).count, 2) + for control in [false, true] { + fx.destroy() + fx = try ScriptFixture() + try fx.installMachinery() + try fx.writeState(#"{"sleepDisabledByUs":false,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) + try fx.writeMarkerBackstop(at: fx.backstop, name: "checkout") + if control { + let text = try String(contentsOf: fx.uninstall, encoding: .utf8) + .replacingOccurrences(of: found, with: "SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n") + try text.write(to: fx.uninstall, atomically: true, encoding: .utf8) + } + let decoy = fx.root.appendingPathComponent("decoy", isDirectory: true) + let decoyScripts = decoy.appendingPathComponent("repo/scripts", isDirectory: true) + try fx.writeMarkerBackstop(at: decoyScripts.appendingPathComponent("backstop.sh"), name: "decoy") + try "// swift-tools-version: 6.2\n".write(to: decoy.appendingPathComponent("repo/Package.swift"), atomically: true, encoding: .utf8) + let shadow = fx.root.appendingPathComponent("shadow", isDirectory: true) + try FileManager.default.createDirectory(at: shadow, withIntermediateDirectories: true) + let standIns = fx.root.appendingPathComponent("stand-ins.log") + // A stand-in answers uninstall.sh only; the fake tools' own + // calls (they read their mode files with cat) go to the real one. + for (tool, real) in [("dirname", "/usr/bin/dirname"), ("cat", "/bin/cat")] { + let url = shadow.appendingPathComponent(tool) + try """ + #!/bin/bash + case "$(/bin/ps -o command= -p "$PPID")" in + *repo/scripts/uninstall.sh*) ;; + *) exec \(real) "$@" ;; + esac + printf '\(tool) %s\\n' "$*" >> '\(standIns.path)' + printf '%s\\n' '\(decoyScripts.path)' + + """.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + } + let relative = fx.root.appendingPathComponent("relative-uninstall.sh") + try "cd '\(fx.root.path)' && exec /bin/bash repo/scripts/uninstall.sh \"$@\"\n".write(to: relative, atomically: true, encoding: .utf8) + + let r = try fx.run(relative, extraEnvironment: ["PATH": "\(shadow.path):/usr/bin:/bin:/usr/sbin:/sbin", "CDPATH": decoy.path]) + + XCTAssertEqual(r.status, 0, "control \(control): " + r.stderr + r.stdout) + let ran = fx.calls().filter { $0.hasPrefix("backstop ") } + if control { + XCTAssertEqual(ran, ["backstop decoy --force"], "the control picks the decoy: \(fx.calls())") + XCTAssertTrue(((try? String(contentsOf: standIns, encoding: .utf8)) ?? "").hasPrefix("dirname "), "the control runs the stand-in dirname") + } else { + XCTAssertEqual(ran, ["backstop checkout --force"], "\(fx.calls())") + XCTAssertTrue(r.stdout.contains("using \(fx.backstop.path)\n"), r.stdout) + XCTAssertFalse(fx.exists(standIns), "a stand-in ran: \((try? String(contentsOf: standIns, encoding: .utf8)) ?? "")") + } + } + } + /// A backstop.sh added beside the zip's uninstall.sh (the zip has none), /// as another account could do in a folder it created in /tmp before the /// zip was unpacked there, is not run. Outside a source checkout @@ -2140,15 +4198,36 @@ final class RecoveryScriptTests: XCTestCase { /// install.sh and uninstall.sh make their calls through the same /// bounded() and supervise(), so a fix to one cannot miss the other. + /// The one tool bounded() takes by name, cat, reads a sudo call's pid, + /// and uninstall.sh calls bounded() only with fixed paths that are not + /// $SUDO, so it never reaches that cat: every call site names one of + /// them, and a whole uninstall with a stand-in cat first in PATH runs + /// no stand-in (testUninstallFindsItsCheckoutWithoutPATHOrCDPATH). func testInstallAndUninstallShareTheBoundedCallHelper() throws { + func text(_ name: String) throws -> String { + try String(contentsOf: ScriptFixture.productionScripts.appendingPathComponent(name), encoding: .utf8) + } func helper(_ name: String) throws -> String { - let text = try String(contentsOf: ScriptFixture.productionScripts.appendingPathComponent(name), encoding: .utf8) + let text = try text(name) let start = try XCTUnwrap(text.range(of: "\nbounded() {"), name) let supervise = try XCTUnwrap(text.range(of: "\nsupervise() {", range: start.upperBound.. [String: ReaderOutput] { + let f = try ScriptFixture() + defer { f.destroy() } + let runner = f.root.appendingPathComponent("record-text-problems.sh") + try (""" + set -euo pipefail + STAT=/usr/bin/stat + HEAD=/usr/bin/head + CMP=/usr/bin/cmp + ICONV=/usr/bin/iconv + GREP='\(grep)' + TEXT_READ_SECONDS=30 + + """ + Self.readerBlock(script) + """ + + record_text_problems "$1" state "$2" > "$1.printed" + /bin/cat "$1.printed" + [[ ! -f "$2" ]] || /usr/bin/plutil -convert json -o /dev/null "$2" + HELD_PREFIX="" + held_count=0 + held_paths=() + held_shown=() + held_raws=() + held_gone=() + held_where=() + while IFS= read -r line; do + case "$line" in + "held: "*) + line="${line#held: }" + token="${line%% *}" + line="${line#* }" + held_paths[held_count]="${line%% *}" + line="${line#* }" + held_shown[held_count]="${line%% *}" + held_raws[held_count]="${line#* }" + HELD_PREFIX="${token%"$held_count"}" + held_count=$((held_count + 1)) + ;; + esac + done < "$1.printed" + if [[ -f "$2" ]] && (( held_count > 0 )); then + json_held_where "" + record_text_problems "$2" state "$3" "" restore > "$3.printed" + fi + + """) + .write(to: runner, atomically: true, encoding: .utf8) + var printed: [String: ReaderOutput] = [:] + for (i, input) in inputs.enumerated() { + let file = f.root.appendingPathComponent("input.\(i)") + let view = f.root.appendingPathComponent("view.\(i)") + let restored = f.root.appendingPathComponent("restored.\(i)") + try input.bytes.write(to: file) + let r = try f.run(runner, [file.path, view.path, restored.path]) + XCTAssertEqual(r.status, 0, "\(input.label): \(r.stderr)") + XCTAssertEqual(r.stderr, "", input.label) + var written: Data? + if f.exists(view) { written = try Data(contentsOf: view) } + let restorePrinted = (try? String(contentsOf: URL(fileURLWithPath: restored.path + ".printed"), encoding: .utf8)) ?? "" + var restoredJournal: Data? + if f.exists(restored) { restoredJournal = try Data(contentsOf: restored) } + printed[input.label] = (r.stdout, written, restorePrinted, restoredJournal) + } + return printed + } + + /// The lines of `script`, which is ASCII. + private static func scriptLines(_ script: String) throws -> [String] { + let text = try String(contentsOf: ScriptFixture.productionScripts.appendingPathComponent(script), encoding: .utf8) + XCTAssertTrue(text.allSatisfy(\.isASCII), script) + return text.components(separatedBy: "\n") + } + + /// The index of the one line of `lines` that is the header of the + /// function `name`, with or without a comment after the brace, and of + /// the first line after it that is a closing brace alone. Fails unless + /// the header is there exactly once and every line between is empty or + /// indented, so that what is taken is the function and nothing else. + private static func functionRange(_ name: String, in lines: [String], script: String) throws -> ClosedRange { + let headers = lines.indices.filter { lines[$0] == "\(name)() {" || lines[$0].hasPrefix("\(name)() { #") } + XCTAssertEqual(headers.count, 1, "\(name) in \(script)") + let start = try XCTUnwrap(headers.first, "\(name) in \(script)") + let end = try XCTUnwrap(lines[start...].firstIndex(of: "}"), "\(name) in \(script)") + for line in lines[(start + 1).. String { + let lines = try scriptLines(script) + return try names.map { name in + lines[try functionRange(name, in: lines, script: script)].joined(separator: "\n") + }.joined(separator: "\n") + } + + /// The reader both scripts carry: from the comment on json_whole, its + /// first function, to the end of record_text_problems, its last. Every + /// line of it is a comment, a function's header or closing brace, empty + /// or indented, so it defines functions and runs nothing. + private static func readerBlock(_ script: String) throws -> String { + let lines = try scriptLines(script) + let first = "# Sets whole_value to the whole number the app's JSONDecoder reads for the" + XCTAssertEqual(lines.filter { $0 == first }.count, 1, script) + let start = try XCTUnwrap(lines.firstIndex(of: first), script) + let end = try functionRange("record_text_problems", in: lines, script: script).upperBound + XCTAssertLessThan(start, end, script) + for line in lines[start...end] { + let header = line.range(of: #"^[a-z_]+\(\) \{( #.*)?$"#, options: .regularExpression) != nil + XCTAssertTrue(line.isEmpty || line.hasPrefix("#") || line.hasPrefix(" ") || line == "}" || header, "\(script): \(line)") + } + return lines[start...end].joined(separator: "\n") + } + + /// Greptile 4219151866: one table of journals, each read by the app + /// (`Store.decodeState`, what `Store.loadState` runs), by the agent's + /// mode of the app's binary (`AgentCutoffsCommand.sessionAnswer`), and + /// by each script's own check as the script runs it: check_journal in + /// backstop.sh, and journal_view then journal_problems in uninstall.sh. + /// For the agent, the record is also read as its own reader takes it + /// (journal_cutoffs). Both scripts accept exactly the journals the app + /// loads here. Where plutil would read the text otherwise than the app + /// (a key the app reads twice or spelled with an escape, a whole number + /// written with a fraction or an exponent, UTF-16 or UTF-32, text plutil + /// cannot parse under a key the app skips), both read the view the + /// reader writes, the same bytes in each script, which the app decodes + /// to the same state as the journal. They accept what the app skips (a + /// key twice in an object it does not read, a \x escape or 1. under a + /// key it does not read, and in a frozen process without a startedAt, + /// or without a startedAtMicros, what follows it: FrozenProcess). On + /// every journal they accept, the agent's reader gives the binary's + /// record: for a record twice, the app's first copy, and for a value + /// the app reads as no record (an object, 1., a \x escape), foreign. + /// The journals the app writes now and wrote before (frozenPids, no + /// record) pass. + func testTheAppTheBinaryAndBothScriptsAcceptTheSameJournals() async throws { + let b = backslash + var full = RuntimeState() + full.sleepDisabledByUs = true + full.lowPowerSetByUs = true + full.dockerFrozen = true + full.frozenProcesses = [FrozenProcess(pid: 5105, identity: ProcessIdentity(startedAt: 1_700_000_000, startedAtMicros: 250_000, bootSession: "0F0F0F0F-1111-2222-3333-444444444444"))] + full.savedAudioOutputs = [SavedAudioOutput(deviceUID: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", volume: 0.5, muted: false, saveID: "a")] + full.savedOutputVolume = 0.25 + full.savedMuted = false + full.savedDisplayBrightness = 0.8 + full.savedKeyboardBrightness = 0.3 + full.appNapOverrides = [AppNapOverride(bundleId: "com.example.agent", previous: nil)] + full.endedSession = "e30=" + full.sessionCutoffs = AgentCutoffs(endFloor: 30, thermalRules: false) + let written = String(decoding: try Store.makeEncoder().encode(full), as: UTF8.self) + // (label, text, the app loads) + let rows: [(label: String, text: String, app: Bool)] = [ + ("the app's journal now", written, true), + ("an older build's journal", #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenPids":[5105,5106],"dockerFrozen":false}"#, true), + ("a frozen process without identity", #"{"frozenProcesses":[{"pid":5105}]}"#, true), + ("no keys", "{}", true), + ("commas before the ends", #"{"frozenProcesses":[],"sleepDisabledByUs":true,}"#, true), + ("keys spelled with escapes", #"{"sleep\#(b)u0044isabledByUs":true,"session\#(b)u0043utoffs":"30 false"}"#, true), + ("an escaped record", #"{"sessionCutoffs":"\#(b)u0033\#(b)u0030 false"}"#, true), + ("other keys and values", #"{"note":{"a":[1,{"b":null}],"c":"\#(b)u00e9"},"sessionCutoffs":"0 true"}"#, true), + ("a record the app does not write", #"{"sessionCutoffs":"96 false"}"#, true), + ("a record of another type", #"{"sessionCutoffs":30}"#, true), + ("a record with a newline", #"{"sessionCutoffs":"30 false\#(b)n"}"#, true), + ("a null record", #"{"sessionCutoffs":null}"#, true), + ("a bool of the wrong type", #"{"sleepDisabledByUs":"yes","sessionCutoffs":"30 false"}"#, false), + ("frozenProcesses of the wrong type", #"{"sleepDisabledByUs":true,"frozenProcesses":"bad","sessionCutoffs":"30 false"}"#, false), + ("a pid of the wrong type", #"{"frozenProcesses":[{"pid":"5105"}]}"#, false), + ("a pid written as 1.0", #"{"frozenProcesses":[{"pid":5105.0}]}"#, true), + ("whole numbers written with a fraction or an exponent", #"{"frozenProcesses":[{"pid":5105.0,"startedAt":1.7e9,"startedAtMicros":25E4,"bootSession":"x"}],"frozenPids":[1e2,-0.0,0e999]}"#, true), + ("a pid that is not whole", #"{"frozenProcesses":[{"pid":5105.5}]}"#, false), + ("a pid past Int32 written as a float", #"{"frozenProcesses":[{"pid":2147483648.0}]}"#, false), + ("a startedAt up to 2^53 written as a float", #"{"frozenProcesses":[{"pid":1,"startedAt":9007199254740992.0}]}"#, true), + ("an identity with a startedAt of 1e18", #"{"frozenProcesses":[{"pid":4242,"startedAt":1e18,"startedAtMicros":0,"bootSession":"test"}]}"#, true), + ("a startedAt of 2^62 written as a float", #"{"frozenProcesses":[{"pid":1,"startedAt":4611686018427387904.0},{"pid":2,"startedAt":-4611686018427387904.0}]}"#, true), + ("a startedAt a Double does not hold, written as a float", #"{"frozenProcesses":[{"pid":1,"startedAt":9007199254740993.0}]}"#, true), + ("the ends of Int64", #"{"frozenProcesses":[{"pid":1,"startedAt":-9223372036854775808},{"pid":2,"startedAt":9223372036854775807}]}"#, true), + ("the lowest Int64 written as a float", #"{"frozenProcesses":[{"pid":1,"startedAt":-9223372036854775808.0}]}"#, false), + ("the ends of Int32 written as floats", #"{"frozenProcesses":[{"pid":2147483647.0}],"frozenPids":[-2147483648.0,21474836.47e2]}"#, true), + ("a pid a Double rounds to whole", #"{"frozenProcesses":[{"pid":1.0000000000000001}]}"#, true), + ("a legacy pid a Double rounds to 0", #"{"frozenPids":[1e-99999]}"#, true), + ("a provisional entry: startedAtMicros of the wrong type, no startedAt", #"{"sleepDisabledByUs":true,"frozenProcesses":[{"pid":4242,"startedAtMicros":"bad","bootSession":"test"}]}"#, true), + ("a provisional entry: bootSession of the wrong type, no startedAtMicros", #"{"sleepDisabledByUs":true,"frozenProcesses":[{"pid":4242,"startedAt":123,"bootSession":42}]}"#, true), + ("a provisional entry: startedAtMicros past Int32, no startedAt", #"{"sleepDisabledByUs":true,"frozenProcesses":[{"pid":4242,"startedAtMicros":2147483648}]}"#, true), + ("a provisional entry: a null startedAt", #"{"frozenProcesses":[{"pid":4242,"startedAt":null,"startedAtMicros":"bad","bootSession":42}]}"#, true), + ("a provisional entry: startedAtMicros twice, no startedAt", #"{"frozenProcesses":[{"pid":4242,"startedAtMicros":1,"startedAtMicros":"x"}]}"#, true), + ("a provisional entry: bootSession twice, no startedAtMicros", #"{"frozenProcesses":[{"pid":4242,"startedAt":123,"bootSession":"a","bootSession":5}]}"#, true), + ("an identity with a bootSession of the wrong type", #"{"frozenProcesses":[{"pid":4242,"startedAt":123,"startedAtMicros":0,"bootSession":42}]}"#, false), + ("an identity with startedAtMicros past Int32", #"{"frozenProcesses":[{"pid":4242,"startedAt":123,"startedAtMicros":2147483648,"bootSession":"test"}]}"#, false), + ("an identity with startedAtMicros of the wrong type", #"{"frozenProcesses":[{"pid":4242,"startedAt":123,"startedAtMicros":"bad","bootSession":"test"}]}"#, false), + ("an identity with startedAtMicros after its bad bootSession", #"{"frozenProcesses":[{"pid":4242,"bootSession":42,"startedAtMicros":0,"startedAt":123}]}"#, false), + ("an identity with startedAtMicros twice", #"{"frozenProcesses":[{"pid":4242,"startedAt":123,"startedAtMicros":1,"startedAtMicros":2,"bootSession":"b"}]}"#, true), + ("a pid too large", #"{"frozenProcesses":[{"pid":2147483648}]}"#, false), + ("a saved output without muted", #"{"savedAudioOutputs":[{"deviceUID":"a","volume":0.5}]}"#, false), + ("an App Nap entry without its bundle", #"{"appNapOverrides":[{"previous":true}]}"#, false), + ("an endedSession of the wrong type", #"{"endedSession":5}"#, false), + ("a level too large", #"{"savedKeyboardBrightness":1e39}"#, false), + ("a key twice", #"{"sleepDisabledByUs":true,"sleepDisabledByUs":false}"#, true), + ("a bad first copy", #"{"frozenProcesses":"bad","frozenProcesses":[]}"#, false), + ("a bad last copy", #"{"frozenProcesses":[],"frozenProcesses":"bad"}"#, true), + ("a nested key twice", #"{"frozenProcesses":[{"pid":5,"pid":"x"}]}"#, true), + ("a key twice in objects the app does not read", #"{"note":{"x":1,"x":2},"frozenProcesses":[{"pid":5,"extra":{"y":1,"y":2}}],"sessionCutoffs":"30 false"}"#, true), + ("a key the app does not read twice", #"{"note":1,"note":2,"savedAudioOutputs":[{"deviceUID":"a","volume":0.5,"muted":false,"x":1,"x":2}]}"#, true), + ("an object under the record", #"{"sessionCutoffs":{"a":1,"a":2}}"#, true), + ("a key twice, once escaped", #"{"sleepDisabledByUs":true,"sleepDisabledBy\#(b)u0055s":"x"}"#, true), + ("a key twice, once with a Kelvin sign", "{\"saved\u{212A}eyboardBrightness\":\"bad\",\"savedKeyboardBrightness\":0.5}", false), + ("a record twice", #"{"sessionCutoffs":"30 false","sessionCutoffs":"0 true"}"#, true), + ("a record with an escape JSON does not have", #"{"sessionCutoffs":"3\#(b)x30 false"}"#, true), + ("a record written as 1.", #"{"sessionCutoffs":1.}"#, true), + ("a bad escape under another key", #"{"note":"\#(b)x41"}"#, true), + ("values the app skips", #"{"note":[1.,-.5,2.e3,1e-400,"\#(b)x41\#(b)'",{"\#(b)x41":1}],"frozenProcesses":[{"pid":1,"x":0.,"y":"\#(b)x41"}],"sessionCutoffs":"0 true"}"#, true), + ("+1 under another key", #"{"note":+1}"#, false), + ("a bad escape in a key of an entry the app reads", #"{"appNapOverrides":[{"bundleId":"a","\#(b)x41":1}]}"#, false), + ("a leading zero under another key", #"{"note":01}"#, true), + ("not an object", #"["sleepDisabledByUs",true]"#, false), + ("not JSON", #"{"sleepDisabledByUs":true,"#, false), + ] + // The app's journal in other encodings the app reads; it writes + // only UTF-8. + let encoded: [(label: String, bytes: Data, app: Bool)] = [ + ("UTF-16 with a byte order mark", Data([0xFF, 0xFE]) + written.data(using: .utf16LittleEndian)!, true), + ("UTF-16 big-endian without a byte order mark", written.data(using: .utf16BigEndian)!, true), + ("UTF-32 without a byte order mark", written.data(using: .utf32LittleEndian)!, true), + ("UTF-32 big-endian without a byte order mark", written.data(using: .utf32BigEndian)!, true), + ("UTF-32 big-endian with a byte order mark", Data([0x00, 0x00, 0xFE, 0xFF]) + written.data(using: .utf32BigEndian)!, true), + ("UTF-32 with a byte order mark", Data([0xFF, 0xFE, 0x00, 0x00]) + written.data(using: .utf32LittleEndian)!, false), + ] + let table = rows.map { (label: $0.label, bytes: Data($0.text.utf8), app: $0.app) } + encoded + let f = try ScriptFixture() + defer { f.destroy() } + var states: [URL] = [] + for (i, row) in table.enumerated() { + let dir = f.root.appendingPathComponent("journal.\(i)") + try FileManager.default.createDirectory(at: dir.appendingPathComponent("uninstall"), withIntermediateDirectories: true) + states.append(dir.appendingPathComponent("state.json")) + try row.bytes.write(to: states[i]) + } + let setup = """ + set -euo pipefail + export LC_ALL=C + RM=/bin/rm + CAT=/bin/cat + PLUTIL=/usr/bin/plutil + HEAD=/usr/bin/head + STAT=/usr/bin/stat + CMP=/usr/bin/cmp + ICONV=/usr/bin/iconv + GREP=/usr/bin/grep + TEXT_READ_SECONDS=30 + + """ + // Prints per journal "refused", or "accepted", the journal the + // script read (state or view), and for backstop.sh the record. Each + // script reads the journals in `parts` runs, each over folders of + // its own; both scripts' runs go at once (runAll), and their lines + // are put back in the journals' order. + let parts = 4 + let size = (states.count + parts - 1) / parts + let chunks = stride(from: 0, to: states.count, by: size).map { Array(states[$0.. [ScriptFixture.Launch] { + let runner = f.root.appendingPathComponent("accept.\(script)") + try (setup + Self.readerBlock(script) + "\n" + Self.scriptFunctions(functions, script: script) + "\n" + loop) + .write(to: runner, atomically: true, encoding: .utf8) + return chunks.map { f.launch(runner, $0.map(\.path)) } + } + func lines(_ script: String, _ results: ArraySlice<(status: Int32, stdout: String, stderr: String)>) -> [String] { + var printed: [String] = [] + for (chunk, r) in zip(chunks, results) { + XCTAssertEqual(r.status, 0, "\(script): \(r.stderr)") + XCTAssertEqual(r.stderr, "", script) + let part = r.stdout.split(separator: "\n").map(String.init) + XCTAssertEqual(part.count, chunk.count, "\(script): \(r.stdout)") + printed += part + } + return printed + } + let agentRuns = try launches("backstop.sh", functions: ["extract", "type_of", "shape_types", "shape_value_type", "shape_type", "shape_name", "journal_shape_problems", "check_journal", "journal_cutoffs"], loop: """ + lock_shared=0 + for STATE in "$@"; do + APP_SUPPORT="${STATE%/*}" + JOURNAL_VIEW="$APP_SUPPORT/backstop-view" + journal_checked=0 + journal_state="" + check_journal + if [[ "$journal_state" != clean ]]; then + echo refused + elif [[ "$JOURNAL" == "$STATE" ]]; then + echo "accepted state $(journal_cutoffs)" + else + echo "accepted view $(journal_cutoffs)" + fi + done + + """) + let uninstallRuns = try launches("uninstall.sh", functions: ["extract", "extract_json", "type_of", "shape_types", "shape_value_type", "shape_type", "shape_name", "journal_shape_problems", "is_refused", "journal_view", "held_text", "journal_problems"], loop: """ + for STATE in "$@"; do + SESSION="${STATE%/*}/session.json" + WORK="${STATE%/*}/uninstall" + journal_view + problems="$(journal_problems)" + if [[ $'\\n'"$problems" == *$'\\n'"state.json is malformed"* || $'\\n'"$problems" == *$'\\n'"state.json is unreadable"* ]]; then + echo refused + elif [[ "$JOURNAL" == "$STATE" ]]; then + echo "accepted state" + else + echo "accepted view" + fi + done + + """) + let results = try await ScriptFixture.runAll(agentRuns + uninstallRuns) + let agent = lines("backstop.sh", results[.. Data { Data(s.utf8) } + let cases: [(label: String, bytes: Data, prints: String, appReads: Bool)] = [ + ("no records", utf8(#"{"sleepDisabledByUs":true}"#), "", true), + ("empty object", utf8("{ }"), "", true), + ("UTF-8 byte order mark", bom + utf8(#"{"keptDisplayReadLit":0.8}"#), "", true), + ("UTF-8 byte order mark, too small", bom + utf8(#"{"keptDisplayReadLit":1e-400}"#), + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("UTF-16 with a byte order mark", Data([0xFF, 0xFE]) + #"{"keptDisplayReadLit":0.8}"#.data(using: .utf16LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n", true), + ("UTF-16 without a byte order mark", #"{"keptDisplayReadLit":0.8}"#.data(using: .utf16LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n", true), + ("UTF-16 big-endian with a byte order mark", Data([0xFE, 0xFF]) + "{\"keptDisplayReadLit\":0.8,\"a\":\"\u{E9}\u{1F600}\"}".data(using: .utf16BigEndian)!, + "view: state.json is UTF-16BE, read here as UTF-8\n", true), + ("UTF-16 big-endian, too small", #"{"keptDisplayReadLit":1e-400}"#.data(using: .utf16BigEndian)!, + "view: state.json is UTF-16BE, read here as UTF-8\n" + + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("UTF-16 with a key twice", #"{"keptDisplayReadLit":0.8,"keptDisplayReadLit":0.7}"#.data(using: .utf16LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n" + + "view: the top level of state.json has keptDisplayReadLit more than once; the app reads the first, and so is it read here\n", true), + ("UTF-32 without a byte order mark", #"{"keptDisplayReadLit":0.8}"#.data(using: .utf32LittleEndian)!, + "view: state.json is UTF-32LE, read here as UTF-8\n", true), + ("UTF-32 without a byte order mark, too small", #"{"keptDisplayReadLit":1e-400}"#.data(using: .utf32LittleEndian)!, + "view: state.json is UTF-32LE, read here as UTF-8\n" + + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("UTF-32 big-endian without a byte order mark", #"{"keptDisplayReadLit":0.8}"#.data(using: .utf32BigEndian)!, + "view: state.json is UTF-32BE, read here as UTF-8\n", true), + ("UTF-32 big-endian with a byte order mark", Data([0x00, 0x00, 0xFE, 0xFF]) + "{\"keptDisplayReadLit\":0.8,\"a\":\"\u{E9}\u{1F600}\"}".data(using: .utf32BigEndian)!, + "view: state.json is UTF-32BE, read here as UTF-8\n", true), + ("UTF-32 big-endian with a byte order mark, too large", Data([0x00, 0x00, 0xFE, 0xFF]) + #"{"keptDisplayReadLit":1e39}"#.data(using: .utf32BigEndian)!, + "view: state.json is UTF-32BE, read here as UTF-8\n" + + "keptDisplayReadLit is 1e39, too large a number for the app's Float\n", false), + ("UTF-32 with a byte order mark", Data([0xFF, 0xFE, 0x00, 0x00]) + #"{"keptDisplayReadLit":0.8}"#.data(using: .utf32LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n" + + "state.json is not a JSON object, which the app's decoder requires\n", false), + ("UTF-16 without a byte order mark, hiding a key", + "{\"a\":\"\u{2278}\u{222C}\u{2271}\u{203A}\u{205B}\",\"keptDisplayReadLit\":1e-400,\"b\":\"\u{2C5D}\u{2220}\u{2263}\u{203A}\u{7822}\"}" + .data(using: .utf16LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n" + + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("UTF-16 without records", #"{"sleepDisabledByUs":true}"#.data(using: .utf16LittleEndian)!, + "view: state.json is UTF-16LE, read here as UTF-8\n", true), + ("NUL byte in a string", utf8("{\"keptDisplayReadLit\":0.8,\"a\":\"x\u{0}y\"}"), + "view: state.json holds a NUL byte in text the app skips, which plutil does not read; left out\n", true), + ("comma before the end", utf8(#"{"keptDisplayReadLit":0.8,}"#), "", true), + ("whitespace everywhere", utf8("\n{ \"a\" :\t[ 1 ,2 ] ,\r\n \"keptDisplayReadLit\"\n:\n0.8\n}\n"), "", true), + ("escaped letter, upper hex", utf8(#"{"kept\#(b)u0044isplayReadLit":1e-400}"#), + "view: the top level of state.json has keptDisplayReadLit written as another key the app reads as keptDisplayReadLit; read here as keptDisplayReadLit\n" + + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("escaped letter, lower hex", utf8(#"{"keptDisplayRead\#(b)u004cit":1e39}"#), + "view: the top level of state.json has keptDisplayReadLit written as another key the app reads as keptDisplayReadLit; read here as keptDisplayReadLit\n" + + "keptDisplayReadLit is 1e39, too large a number for the app's Float\n", false), + ("escaped letter, valid value", utf8(#"{"keptDisplayReadL\#(b)u0069t":0.8}"#), + "view: the top level of state.json has keptDisplayReadLit written as another key the app reads as keptDisplayReadLit; read here as keptDisplayReadLit\n", true), + ("other escapes in keys", utf8(#"{"a\#(b)"\#(b)\#(b)\#(b)/\#(b)b\#(b)f\#(b)n\#(b)r\#(b)t\#(b)u00e9\#(b)ud83d\#(b)ude00":1}"#), "", true), + ("a key with a \\x escape", utf8(#"{"kept\#(b)x44isplayReadLit":0.8}"#), + "a key in the top level of state.json is text the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("unquoted key", utf8(#"{keptDisplayReadLit:0.8}"#), + "the text of state.json is not JSON the app's decoder reads\n", false), + ("block comment", utf8(#"{"a":1,/* c */"keptDisplayReadLit":0.8}"#), + "the text of state.json is not JSON the app's decoder reads\n", false), + ("line comment", utf8("{\"a\":1, // c\n\"keptDisplayReadLit\":0.8}"), + "the text of state.json is not JSON the app's decoder reads\n", false), + ("escaped backslash in a value", utf8(#"{"name":"Headset \#(b)\#(b)u0041","uid":"\#(b)\#(b)"}"#), "", true), + ("escape in a value", utf8(#"{"name":"Headset \#(b)u0041 \#(b)"keptDisplayReadLit\#(b)":1e-400"}"#), "", true), + ("nested copies", utf8(#"{"keptDisplayReadLit":0.8,"a":{"keptDisplayReadLit":1e-400,"b":[["keptDisplayReadLit",{"keptDisplayReadLit":0.7}]]}}"#), "", true), + ("brackets in nested strings", utf8(#"{"a":{"b":"}]","c":["{[",{"d":"\#(b)"}"}]},"keptDisplayReadLit":1e-400}"#), + "keptDisplayReadLit is 1e-400, which the app reads as a Float that is not 0 and rounds to 0, and throws\n", false), + ("read twice", utf8(#"{"keptDisplayReadLit":0.8,"keptDisplayReadL\#(b)u0069t":0.8}"#), + "view: the top level of state.json has keptDisplayReadLit more than once; the app reads the first, and so is it read here\n", true), + ("boot read twice", utf8(#"{"keptDisplayUnderLowPowerBoot":"a","keptDisplayUnderLowPowerBoot":null}"#), + "view: the top level of state.json has keptDisplayUnderLowPowerBoot more than once; the app reads the first, and so is it read here\n", true), + ("zero forms", utf8(#"{"keptDisplayReadLit":-0,"keptDisplayUnderLowPower":0e-400}"#), + "view: keptDisplayReadLit is written as -0, which plutil would write back as text the app reads as another Float; read here as -0.0\n", true), + ("leading zero", utf8(#"{"keptDisplayReadLit":01}"#), + "keptDisplayReadLit is written as 01, which is not a JSON number the app's decoder reads\n", false), + ("any key read twice", utf8(#"{"sleepDisabledByUs":true,"sleepDisabledByUs":false}"#), + "view: the top level of state.json has sleepDisabledByUs more than once; the app reads the first, and so is it read here\n", true), + ("a nested key read twice", utf8(#"{"frozenProcesses":[{"pid":5,"p\#(b)u0069d":6}]}"#), + "view: frozenProcesses[0] has pid more than once; the app reads the first, and so is it read here\n", true), + ("a key twice in an object the app does not read", utf8(#"{"a":{"x":1,"x":2}}"#), "", true), + ("a key twice in an object under an entry", utf8(#"{"frozenProcesses":[{"pid":1,"a":{"x":1,"x":2}}]}"#), "", true), + ("a key the app does not read twice", utf8(#"{"a":1,"a":2,"frozenProcesses":[{"pid":1,"x":1,"x":2}]}"#), "", true), + ("a key twice in an entry the app reads", utf8(#"{"savedAudioOutputs":[{"deviceUID":"a","volume":0.5,"muted":false,"device\#(b)u0055ID":"b"}]}"#), + "view: savedAudioOutputs[0] has deviceUID more than once; the app reads the first, and so is it read here\n", true), + ("an App Nap key twice", utf8(#"{"appNapOverrides":[{"bundleId":"a","previous":true,"previous":null}]}"#), + "view: appNapOverrides[0] has previous more than once; the app reads the first, and so is it read here\n", true), + ("an object under the record", utf8(#"{"sessionCutoffs":{"a":1,"a":2,"b":"\#(b)x41"}}"#), + "record: sessionCutoffs is an object, which the app does not read as a record\n", true), + ("a record twice", utf8(#"{"sessionCutoffs":"30 false","session\#(b)u0043utoffs":"0 true"}"#), + "view: the top level of state.json has sessionCutoffs more than once; the app reads the first, and so is it read here\n", true), + ("a record with a \\x escape", utf8(#"{"sessionCutoffs":"3\#(b)x30 false"}"#), + "record: sessionCutoffs is a string the app does not read as a record\n", true), + ("a record written as +1", utf8(#"{"sessionCutoffs":+1}"#), + "sessionCutoffs is written as +1, which is not a JSON value the app's decoder reads\n", false), + ("a record written as 1.", utf8(#"{"sessionCutoffs":1.}"#), + "record: sessionCutoffs is written as 1., which the app does not read as a record\n", true), + ("one key in two objects", utf8(#"{"a":{"x":1},"b":{"x":2}}"#), "", true), + ("a Kelvin sign spelling a key twice", utf8("{\"saved\u{212A}eyboardBrightness\":\"bad\",\"savedKeyboardBrightness\":0.5}"), + "view: the top level of state.json has savedKeyboardBrightness written as another key the app reads as savedKeyboardBrightness; read here as savedKeyboardBrightness\n" + + "view: the top level of state.json has savedKeyboardBrightness more than once; the app reads the first, and so is it read here\n", false), + ("an escaped Kelvin sign spelling a key twice", utf8(#"{"savedKeyboardBrightness":0.5,"saved\#(b)u212AeyboardBrightness":0.4}"#), + "view: the top level of state.json has savedKeyboardBrightness more than once; the app reads the first, and so is it read here\n", true), + ("commas before the ends inside", utf8(#"{"a":[1,2,],"b":{"c":1,}}"#), "", true), + ("an Int32 too large", utf8(#"{"frozenProcesses":[{"pid":2147483648}]}"#), + "frozenProcesses[0].pid is 2147483648, which the app's decoder does not read as a whole number it holds there\n", false), + ("the ends of Int32 and Int64", utf8(#"{"frozenProcesses":[{"pid":-2147483648,"startedAt":9223372036854775807,"startedAtMicros":2147483647}]}"#), "", true), + ("an Int64 too large", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":9223372036854775808}]}"#), + "frozenProcesses[0].startedAt is 9223372036854775808, which the app's decoder does not read as a whole number it holds there\n", false), + ("a legacy pid too small", utf8(#"{"frozenPids":[1,2,-2147483649]}"#), + "frozenPids[2] is -2147483649, which the app's decoder does not read as a whole number it holds there\n", false), + ("an output volume too large", utf8(#"{"savedAudioOutputs":[{"deviceUID":"a","volume":1e39,"muted":false}]}"#), + "savedAudioOutputs[0].volume is 1e39, too large a number for the app's Float\n", false), + ("a saved level written as 1.", utf8(#"{"savedOutputVolume":1.}"#), + "savedOutputVolume is written as 1., which is not a JSON number the app's decoder reads\n", false), + ("not a JSON number in an object the app does not read", utf8(#"{"note":{"a":+1}}"#), + "note.a is written as +1, which is not a JSON value the app's decoder reads\n", false), + ("single quotes in an array", utf8(#"{"note":['s']}"#), + "note[0] is written as 's', which is not a JSON value the app's decoder reads\n", false), + ("a \\x escape in a value the app reads", utf8(#"{"endedSession":"abc\#(b)x41"}"#), + "endedSession is a string the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("a \\x escape in a value the app does not read", utf8(#"{"note":"abc\#(b)x41"}"#), "", true), + ("a \\x escape in a key the app does not read", utf8(#"{"note":{"\#(b)x41":1},"frozenProcesses":[{"pid":1,"y":{"\#(b)x41":"\#(b)'"}}]}"#), "", true), + ("a \\x escape in a key of an entry the app reads", utf8(#"{"appNapOverrides":[{"bundleId":"a","\#(b)x41":1}]}"#), + "a key in appNapOverrides[0] is text the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("a \\x escape in a saved output's name", utf8(#"{"savedAudioOutputs":[{"deviceUID":"a","name":"\#(b)x41","volume":0.5,"muted":false}]}"#), + "savedAudioOutputs[0].name is a string the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("numbers the app skips", utf8(#"{"note":[1.,-.5,2.e3,1e-400,1-2],"frozenProcesses":[{"pid":1,"x":0.}]}"#), "", true), + (".5 under a key the app does not read", utf8(#"{"frozenProcesses":[{"pid":1,"x":.5}]}"#), + "frozenProcesses[0].x is written as .5, which is not a JSON value the app's decoder reads\n", false), + ("a pid written as 1.0", utf8(#"{"frozenProcesses":[{"pid":5105.0,"startedAt":1.7e9,"startedAtMicros":25E4}]}"#), + "view: frozenProcesses[0].pid is written as 5105.0, which the app reads as 5105; read here as 5105\n" + + "view: frozenProcesses[0].startedAt is written as 1.7e9, which the app reads as 1700000000; read here as 1700000000\n" + + "view: frozenProcesses[0].startedAtMicros is written as 25E4, which the app reads as 250000; read here as 250000\n", true), + ("a pid that is not whole", utf8(#"{"frozenProcesses":[{"pid":5105.5}]}"#), + "frozenProcesses[0].pid is 5105.5, which the app's decoder does not read as a whole number it holds there\n", false), + ("whole numbers within Int32 written with a fraction or an exponent", utf8(#"{"frozenPids":[214748364.7e1,-2147483648.0,0e999,-0.0,100e-2]}"#), + "view: frozenPids[0] is written as 214748364.7e1, which the app reads as 2147483647; read here as 2147483647\n" + + "view: frozenPids[1] is written as -2147483648.0, which the app reads as -2147483648; read here as -2147483648\n" + + "view: frozenPids[2] is written as 0e999, which the app reads as 0; read here as 0\n" + + "view: frozenPids[3] is written as -0.0, which the app reads as 0; read here as 0\n" + + "view: frozenPids[4] is written as 100e-2, which the app reads as 1; read here as 1\n", true), + ("a whole number past Int32 written with an exponent", utf8(#"{"frozenPids":[21474836480e-1]}"#), + "frozenPids[0] is 21474836480e-1, which the app's decoder does not read as a whole number it holds there\n", false), + ("a startedAt up to 2^53 written with a fraction", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":9007199254740992.0}]}"#), + "view: frozenProcesses[0].startedAt is written as 9007199254740992.0, which the app reads as 9007199254740992; read here as 9007199254740992\n", true), + ("a startedAt of 1e18", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":1e18}]}"#), + "view: frozenProcesses[0].startedAt is written as 1e18, which the app reads as 1000000000000000000; read here as 1000000000000000000\n", true), + ("a startedAt of 2^62 written with a fraction", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":4611686018427387904.0},{"pid":2,"startedAt":-4611686018427387904.0}]}"#), + "view: frozenProcesses[0].startedAt is written as 4611686018427387904.0, which the app reads as 4611686018427387904; read here as 4611686018427387904\n" + + "view: frozenProcesses[1].startedAt is written as -4611686018427387904.0, which the app reads as -4611686018427387904; read here as -4611686018427387904\n", true), + ("a startedAt a Double does not hold, written with a fraction", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":9007199254740993.0}]}"#), + "view: frozenProcesses[0].startedAt is written as 9007199254740993.0, which the app reads as 9007199254740993; read here as 9007199254740993\n", true), + ("the lowest Int64 written with a fraction", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":-9223372036854775808.0}]}"#), + "frozenProcesses[0].startedAt is -9223372036854775808.0, which the app's decoder does not read as a whole number it holds there\n", false), + ("startedAtMicros past Int32, no startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAtMicros":2147483648}]}"#), "", true), + ("startedAtMicros past Int32 after a null startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":null,"startedAtMicros":2147483648}]}"#), "", true), + ("startedAtMicros past Int32 after a startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":2147483648}]}"#), + "frozenProcesses[0].startedAtMicros is 2147483648, which the app's decoder does not read as a whole number it holds there\n", false), + ("startedAtMicros past Int32 before a startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAtMicros":2147483648,"startedAt":5}]}"#), + "frozenProcesses[0].startedAtMicros is 2147483648, which the app's decoder does not read as a whole number it holds there\n", false), + ("startedAtMicros past Int32 in the second entry only", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":1,"bootSession":"b"},{"pid":2,"startedAtMicros":2147483648}]}"#), "", true), + ("a \\x escape in bootSession, no startedAtMicros", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"bootSession":"\#(b)x41"}]}"#), + "view: frozenProcesses[0].bootSession, which the app does not read there, is a string the app would not read; left out\n", true), + ("a \\x escape in bootSession of an identity", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":0,"bootSession":"\#(b)x41"}]}"#), + "frozenProcesses[0].bootSession is a string the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("startedAtMicros written as +1, no startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAtMicros":+1}]}"#), + "frozenProcesses[0].startedAtMicros is written as +1, which is not a JSON value the app's decoder reads\n", false), + ("startedAtMicros twice, no startedAt", utf8(#"{"frozenProcesses":[{"pid":1,"startedAtMicros":1,"startedAtMicros":2}]}"#), + "view: frozenProcesses[0] has startedAtMicros more than once; the app reads the first, and so is it read here\n", true), + ("startedAtMicros twice in an identity", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":1,"startedAtMicros":2,"bootSession":"b"}]}"#), + "view: frozenProcesses[0] has startedAtMicros more than once; the app reads the first, and so is it read here\n", true), + ("a pid a Double rounds to whole", utf8(#"{"frozenProcesses":[{"pid":1.0000000000000001}]}"#), + "view: frozenProcesses[0].pid is written as 1.0000000000000001, which the app reads as 1; read here as 1\n", true), + // A Double rounds this one to 0. + ("a pid with a very small exponent", utf8(#"{"frozenPids":[1e-99999]}"#), + "view: frozenPids[0] is written as 1e-99999, which the app reads as 0; read here as 0\n", true), + ] + try checkRecordReader(cases) + } + + /// Runs the reader of each script over each text (`recordTextProblems`) + /// and checks what it prints, the view it writes, and the app's decoder + /// on the same bytes. Both scripts print the same and write the same + /// view. A view is written exactly when the reader prints nothing or + /// only "view: ", "held: " and "record: " lines, and the app decodes it + /// to the state it decodes from the text, bit for bit (each Float too, + /// as the app's encoder writes it). A view that holds placeholders for + /// strings that hold \u0000 ("held: " lines, json_held_token) holds + /// each once, and is checked with each put back here as its string's + /// own text, and as every journal published from it is + /// (journal_candidate_ok): read again with restore, which prints only + /// "view: " and "record: " lines and writes a journal that holds each + /// string's own text and no placeholder, which the app decodes the + /// same way, bit for bit. + private func checkRecordReader(_ cases: [(label: String, bytes: Data, prints: String, appReads: Bool)], grep: String = "/usr/bin/grep") throws { + let printed = try recordTextProblems(cases.map { ($0.label, $0.bytes) }, grep: grep) + let printedByUninstall = try recordTextProblems(cases.map { ($0.label, $0.bytes) }, script: "uninstall.sh", grep: grep) + for c in cases { + let backstop = try XCTUnwrap(printed[c.label], c.label) + let uninstall = try XCTUnwrap(printedByUninstall[c.label], c.label) + XCTAssertEqual(backstop.printed, c.prints, c.label) + XCTAssertEqual(uninstall.printed, c.prints, c.label) + XCTAssertEqual(uninstall.view, backstop.view, c.label) + XCTAssertEqual(uninstall.restorePrinted, backstop.restorePrinted, c.label) + XCTAssertEqual(uninstall.restored, backstop.restored, c.label) + let app = try? Store.makeDecoder().decode(RuntimeState.self, from: c.bytes) + XCTAssertEqual(app != nil, c.appReads, "the app's decoder on \(c.label)") + let lines = c.prints.split(separator: "\n") + let onlyViewLines = lines.allSatisfy { $0.hasPrefix("view: ") || $0.hasPrefix("held: ") || $0.hasPrefix("record: ") } + XCTAssertEqual(backstop.view != nil, onlyViewLines, "a view for \(c.label)") + // held: + let held = lines.filter { $0.hasPrefix("held: ") }.map { line in + line.dropFirst("held: ".count).split(separator: " ", maxSplits: 3, omittingEmptySubsequences: false).map(String.init) + } + XCTAssertEqual(backstop.restored != nil, backstop.view != nil && !held.isEmpty, "a restored journal for \(c.label)") + guard let view = backstop.view else { continue } + var text = String(decoding: view, as: UTF8.self) + for h in held where h.count == 4 { + XCTAssertEqual(text.components(separatedBy: "\"\(h[0])\"").count, 2, "\(c.label): \(h[0]) once in \(text)") + text = text.replacingOccurrences(of: "\"\(h[0])\"", with: "\"\(h[3])\"") + } + var journals = [("the view", Data(text.utf8))] + if let restored = backstop.restored { + journals.append(("the restored view", restored)) + XCTAssertTrue(backstop.restorePrinted.split(separator: "\n").allSatisfy { $0.hasPrefix("view: ") || $0.hasPrefix("record: ") }, "\(c.label): \(backstop.restorePrinted)") + let restoredText = String(decoding: restored, as: UTF8.self) + for h in held where h.count == 4 { + XCTAssertTrue(restoredText.contains("\"\(h[3])\""), "\(c.label): \(h[3]) in \(restoredText)") + XCTAssertFalse(restoredText.contains(h[0]), "\(c.label): \(restoredText)") + } + } + for (name, journal) in journals { + let read = try? Store.makeDecoder().decode(RuntimeState.self, from: journal) + XCTAssertEqual(read, app, "the app reads \(name) of \(c.label) as the text") + XCTAssertEqual(try read.map { try Store.makeEncoder().encode($0) }, try app.map { try Store.makeEncoder().encode($0) }, "\(name) of \(c.label), bit for bit") + } + } + } + + /// Review35 R35-1: the reader over Floats and NULs, as + /// checkRecordReader checks it. A Float plutil would write back as text + /// the app reads as another Float (it reads up to 17 digits through a + /// Double and up to 38 through a Decimal, and "-0" as the whole number + /// 0) is held in the view as the same Float in 9 digits, -0 as -0.0; + /// one plutil keeps stays as written. A NUL byte in text the app skips, + /// which plutil does not read, is left out of the view; one the app + /// refuses (in a string it reads, between values) is refused. + /// + /// Review38 R38-1: a string the app reads that holds \u0000, which + /// plutil cannot hold, is held in the view as a placeholder + /// (json_held_token), and every journal published from the view puts + /// the string's own text back (checkRecordReader reads the view again + /// with restore): in each place the app reads a string (an audio + /// output's deviceUID, name and saveID, an App Nap entry, the kept + /// record's boot, a frozen process's bootSession, endedSession) and in + /// a frozen process's bootSession the app does not read, which the + /// scripts compare; of any length, as many as there are, after escaped + /// backslashes, beside U+E000 written raw or as an escape in either + /// case, in the same string or another, read or not; only the first + /// copy of a key, the one the app reads. Where the app does not read + /// the string, or reads it as no record, it goes as any such string. + /// The placeholders start with insomnia-held--, a start no text of + /// the journal's holds, its letters, digits and dashes written as such + /// or as \u escapes (json_held_prefix), so that no string of the + /// journal's is read as one: k is one more than the greatest canonical + /// k of at most 18 digits the text holds, or, where the text holds that + /// k as well (Greptile 4239597182: 1000000000000000000 after eighteen + /// 9s), one of fewer digits it does not hold, chosen a digit at a time. + func testTheRecordReaderKeepsFloatsAndNULsAsTheAppReadsThem() throws { + let b = backslash + func utf8(_ s: String) -> Data { Data(s.utf8) } + let nulview = "holds \(b)u0000, which plutil cannot hold; held in the view as a placeholder string (json_held_token), which each journal published from it writes back as the string's own text" + // The "held: " line for placeholder n, the string's text `raw`. + func held(_ n: Int, _ path: String, _ shown: String, _ raw: String, k: String = "0") -> String { + "held: insomnia-held-\(k)-\(n) \(path) \(shown) \(raw)\n" + } + // The lines for a string the app reads at `path` that holds \u0000. + func marked(_ n: Int, _ path: String, _ shown: String, _ raw: String, k: String = "0") -> String { + held(n, path, shown, raw, k: k) + "view: \(shown) \(nulview)\n" + } + func twice(_ shown: String, _ key: String) -> String { + "view: \(shown) has \(key) more than once; the app reads the first, and so is it read here\n" + } + let skippedNUL = "view: state.json holds a NUL byte in text the app skips, which plutil does not read; left out\n" + let bundle = ("appNapOverrides[].bundleId", "appNapOverrides[0].bundleId") + let long = String(repeating: "n", count: 1100) + "\(b)u0000" + let many = String(repeating: String(repeating: "n", count: 1000) + "\(b)u0000", count: 70) + let nines = String(repeating: "9", count: 18) + let after = "1" + String(repeating: "0", count: 18) + // Each digit as a \u escape, 6 characters a digit. + func escaped(_ digits: String) -> String { digits.map { "\(b)u003\($0)" }.joined() } + let starts = ["0", "0", "1", "2", "3", "4", "5", "5", "7", "8", "9", "00", nines, after].map { #""insomnia-held-\#($0)-""# }.joined(separator: ",") + let cases: [(label: String, bytes: Data, prints: String, appReads: Bool)] = [ + ("NUL byte in a key the app skips", utf8("{\"a\":{\"x\u{0}\":1}}"), skippedNUL, true), + ("NUL byte in a string the app reads", utf8("{\"appNapOverrides\":[{\"bundleId\":\"a\u{0}b\"}]}"), + "appNapOverrides[0].bundleId is a string the app's decoder does not read (a control character, bytes that are not UTF-8, an escape JSON does not have, or a lone surrogate)\n", false), + ("NUL byte between values", utf8("{\"a\":1,\u{0}\"b\":2}"), "the text of state.json is not JSON the app's decoder reads\n", false), + ("\\u0000 in a device UID", utf8(#"{"savedAudioOutputs":[{"deviceUID":"a\#(b)u0000b","volume":0.5,"muted":false}]}"#), + marked(0, "savedAudioOutputs[].deviceUID", "savedAudioOutputs[0].deviceUID", "a\(b)u0000b"), true), + ("\\u0000 after escaped backslashes", utf8(#"{"appNapOverrides":[{"bundleId":"a\#(b)\#(b)u0000\#(b)\#(b)\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)\(b)u0000\(b)\(b)\(b)u0000"), true), + ("\\u0000 in the kept record's boot", utf8(#"{"keptDisplayUnderLowPower":0.8,"keptDisplayUnderLowPowerBoot":"\#(b)u0000"}"#), + marked(0, "keptDisplayUnderLowPowerBoot", "keptDisplayUnderLowPowerBoot", "\(b)u0000"), true), + ("\\u0000 in a bootSession", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":0,"bootSession":"b\#(b)u0000"}]}"#), + held(0, "frozenProcesses[].bootSession", "frozenProcesses[0].bootSession", "b\(b)u0000") + + "view: frozenProcesses[0].bootSession \(nulview)\n", true), + ("\\u0000 in a bootSession the app does not read", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"bootSession":"b\#(b)u0000"}]}"#), + held(0, "frozenProcesses[].bootSession", "frozenProcesses[0].bootSession", "b\(b)u0000") + + "view: frozenProcesses[0].bootSession, which the app does not read there, \(nulview)\n", true), + ("\\u0000 in an endedSession", utf8(#"{"endedSession":"e\#(b)u0000"}"#), marked(0, "endedSession", "endedSession", "e\(b)u0000"), true), + ("\\u0000 in a record", utf8(#"{"sessionCutoffs":"30\#(b)u0000 false"}"#), + "record: sessionCutoffs is a string the app does not read as a record\n", true), + ("\\u0000 where the app does not read it", utf8(#"{"note":"\#(b)u0000","appNapOverrides":[{"bundleId":"a","a\#(b)u0000":1}]}"#), "", true), + ("U+E000 without \\u0000", utf8("{\"appNapOverrides\":[{\"bundleId\":\"\u{E000}\"}],\"note\":\"\(b)uE000\"}"), "", true), + ("\\u0000 and U+E000", utf8("{\"appNapOverrides\":[{\"bundleId\":\"a\(b)u0000\"},{\"bundleId\":\"\u{E000}\"}]}"), + marked(0, bundle.0, bundle.1, "a\(b)u0000"), true), + ("\\u0000 and U+E000 as an escape", utf8(#"{"appNapOverrides":[{"bundleId":"a\#(b)u0000"}],"note":"\#(b)ue000"}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000"), true), + ("\\u0000 beside U+E000 in every form, read and not", + utf8("{\"appNapOverrides\":[{\"bundleId\":\"\u{E000}a\(b)u0000\(b)uE000\(b)ue000\"},{\"bundleId\":\"\u{E000}\(b)uE000\"}],\"note\":\"\u{E000}\(b)ue000\(b)u0000\",\"savedAudioOutputs\":[{\"deviceUID\":\"\(b)ue000\",\"name\":\"\u{E000}\",\"volume\":0.5,\"muted\":false}]}"), + marked(0, bundle.0, bundle.1, "\u{E000}a\(b)u0000\(b)uE000\(b)ue000"), true), + ("\\u0000 in a string longer than 1024 bytes", + utf8(#"{"savedAudioOutputs":[{"deviceUID":"a","name":""# + long + #"","volume":0.5,"muted":false}]}"#), + marked(0, "savedAudioOutputs[].name", "savedAudioOutputs[0].name", long), true), + ("\\u0000 70 times in a string of 70 KB", + utf8(#"{"endedSession":""# + many + #"","savedAudioOutputs":[{"deviceUID":"a","name":""# + many + #"","volume":0.5,"muted":false}]}"#), + marked(0, "endedSession", "endedSession", many) + marked(1, "savedAudioOutputs[].name", "savedAudioOutputs[0].name", many), true), + ("\\u0000 in every string of an entry and in several entries", + utf8(#"{"savedAudioOutputs":[{"deviceUID":"u\#(b)u0000","name":"n\#(b)u0000","volume":0.5,"muted":false,"saveID":"s\#(b)u0000"},{"deviceUID":"v\#(b)u0000","volume":0.25,"muted":true}],"appNapOverrides":[{"bundleId":"a\#(b)u0000"},{"bundleId":"b"},{"bundleId":"c\#(b)u0000"}],"endedSession":"e\#(b)u0000"}"#), + marked(0, "savedAudioOutputs[].deviceUID", "savedAudioOutputs[0].deviceUID", "u\(b)u0000") + + marked(1, "savedAudioOutputs[].name", "savedAudioOutputs[0].name", "n\(b)u0000") + + marked(2, "savedAudioOutputs[].saveID", "savedAudioOutputs[0].saveID", "s\(b)u0000") + + marked(3, "savedAudioOutputs[].deviceUID", "savedAudioOutputs[1].deviceUID", "v\(b)u0000") + + marked(4, bundle.0, bundle.1, "a\(b)u0000") + + marked(5, bundle.0, "appNapOverrides[2].bundleId", "c\(b)u0000") + + marked(6, "endedSession", "endedSession", "e\(b)u0000"), true), + ("\\u0000 in both copies of a key", utf8(#"{"endedSession":"a\#(b)u0000","ended\#(b)u0053ession":"b\#(b)u0000"}"#), + marked(0, "endedSession", "endedSession", "a\(b)u0000") + twice("the top level of state.json", "endedSession"), true), + ("\\u0000 in the second copy of a key alone", utf8(#"{"endedSession":"a","endedSession":"b\#(b)u0000"}"#), + twice("the top level of state.json", "endedSession"), true), + ("\\u0000 in both copies of a key in an entry", utf8(#"{"appNapOverrides":[{"bundleId":"a\#(b)u0000","previous":true,"bundleId":"b\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000") + twice("appNapOverrides[0]", "bundleId"), true), + ("\\u0000 in both copies of a bootSession", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":0,"bootSession":"b\#(b)u0000","bootSession":"c\#(b)u0000"}]}"#), + held(0, "frozenProcesses[].bootSession", "frozenProcesses[0].bootSession", "b\(b)u0000") + + twice("frozenProcesses[0]", "bootSession") + + "view: frozenProcesses[0].bootSession \(nulview)\n", true), + ("\\u0000 in keys and values the app skips beside one it reads", + utf8(#"{"note":"x\#(b)u0000","a\#(b)u0000":"\#(b)u0000","appNapOverrides":[{"bundleId":"a\#(b)u0000","x\#(b)u0000":"\#(b)u0000","y":["\#(b)u0000"]}],"frozenProcesses":[{"pid":1,"z":"\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000"), true), + ("a placeholder's start in other strings", + utf8(#"{"note":"insomnia-held-0-0","appNapOverrides":[{"bundleId":"a\#(b)u0000"},{"bundleId":"insomnia-held-0-1"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "1"), true), + ("a placeholder's start written with escapes", + utf8(#"{"note":"\#(b)u0069nsomnia\#(b)u002dheld\#(b)u002D7\#(b)u002d","x":"insomnia-held-\#(b)u00312-","appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "13"), true), + ("a placeholder's start in the string that holds \\u0000", + utf8(#"{"appNapOverrides":[{"bundleId":"insomnia-held-0-0\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "insomnia-held-0-0\(b)u0000", k: "1"), true), + ("a placeholder's start with 18 digits", + utf8(#"{"note":"insomnia-held-999999999999999998-","appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "999999999999999999"), true), + ("a placeholder's start after eighteen 9s, beside others of 19 and 20 digits", + utf8(#"{"note":["insomnia-held-999999999999999999-","insomnia-held-1000000000000000001-","insomnia-held-10000000000000000000-"],"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "1000000000000000000"), true), + // Greptile 4239597182: the text holds the start after eighteen + // 9s too, so k is a start it does not hold. + ("saved output names that start as placeholders after eighteen 9s do", + utf8(#"{"savedAudioOutputs":[{"deviceUID":"u\#(b)u0000","name":"insomnia-held-999999999999999999-","volume":0.5,"muted":false},{"deviceUID":"v","name":"insomnia-held-1000000000000000000-0","volume":0.5,"muted":false}]}"#), + marked(0, "savedAudioOutputs[].deviceUID", "savedAudioOutputs[0].deviceUID", "u\(b)u0000"), true), + ("the starts after eighteen 9s and after 0, each digit an escape (19 digits in 114 characters)", + utf8(#"{"note":["\#(b)u0069nsomnia\#(b)u002Dheld-\#(escaped(nines))-","insomnia\#(b)u002dheld\#(b)u002d\#(escaped(after))\#(b)u002D","insomnia-held-\#(escaped("0"))-"],"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "1"), true), + ("the start after eighteen 9s, 0 to 9 but 6 with 0 and 5 twice, and 00", + utf8(#"{"note":[\#(starts)],"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000", k: "10"), true), + ("starts that leave k at 0: a leading zero, 19 digits, no dash after", + utf8(#"{"note":["insomnia-held-07-","insomnia-held-1234567890123456789-","insomnia-held-5"],"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + marked(0, bundle.0, bundle.1, "a\(b)u0000"), true), + ("a Float plutil would write back as another", utf8(#"{"keptDisplayReadLit":0.5000000298023223876953125000000000000001}"#), + "view: keptDisplayReadLit is written as 0.50000002980232238769531250000000000000, which plutil would write back as text the app reads as another Float; read here as 0.50000006\n", true), + ("a Float at the midpoint of two", utf8(#"{"keptDisplayReadLit":0.5000000298023223876953125}"#), + "view: keptDisplayReadLit is written as 0.5000000298023223876953125, which plutil would write back as text the app reads as another Float; read here as 0.5\n", true), + ("negative zero Floats", utf8(#"{"keptDisplayReadLit":-0,"savedDisplayBrightness":-0.0}"#), + "view: keptDisplayReadLit is written as -0, which plutil would write back as text the app reads as another Float; read here as -0.0\n", true), + ("a Float plutil keeps", utf8(#"{"keptDisplayReadLit":0.30000001192092896}"#), "", true), + ] + try checkRecordReader(cases) + } + + /// Review38 R38-1: where no placeholder can be had for a string the + /// app reads that holds \u0000 (GREP fails, so json_held_prefix cannot + /// tell which start no string of the journal's holds), the reader says + /// what the app reads is not known here and writes no view, a read + /// bootSession and one the app does not read alike. A journal with no + /// such string never runs GREP and passes. + func testTheRecordReaderRefusesAStringThatHoldsNULWhenNoPlaceholderCanBeHad() throws { + let b = backslash + func utf8(_ s: String) -> Data { Data(s.utf8) } + let f = try ScriptFixture() + defer { f.destroy() } + let grep = f.root.appendingPathComponent("grep-fails") + try "#!/bin/bash\nexit 2\n".write(to: grep, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: grep.path) + let unknown = "holds \(b)u0000, which plutil cannot hold, and no placeholder for it could be had here, so what the app reads is not known here\n" + try checkRecordReader([ + ("an App Nap entry", utf8(#"{"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), "appNapOverrides[0].bundleId \(unknown)", true), + ("a bootSession the app reads", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"startedAtMicros":0,"bootSession":"b\#(b)u0000"}]}"#), + "frozenProcesses[0].bootSession \(unknown)", true), + ("a bootSession the app does not read", utf8(#"{"frozenProcesses":[{"pid":1,"startedAt":5,"bootSession":"b\#(b)u0000"}]}"#), + "frozenProcesses[0].bootSession \(unknown)", true), + ("an App Nap entry beside names that start as placeholders after eighteen 9s do", + utf8(#"{"savedAudioOutputs":\#(Self.namesAfterEighteenNines.json),"appNapOverrides":[{"bundleId":"a\#(b)u0000"}]}"#), + "savedAudioOutputs[0].deviceUID \(unknown)appNapOverrides[0].bundleId \(unknown)", true), + ("no string that holds \\u0000", utf8(#"{"appNapOverrides":[{"bundleId":"a"}],"note":"\#(b)u0000"}"#), "", true), + ], grep: grep.path) + } + + /// A journal the app loads whose Floats plutil would write back as + /// others, or whose strings the app reads hold \u0000, what a backstop + /// run over it calls and exits with, and what the journal it publishes + /// holds afterwards: the app's state, and each string's own text in + /// `raws` (as JSON text, between the quotes) that the run keeps. `ending` + /// journals go with a valid session whose end is recorded in the + /// journal. + private typealias PublishedJournalRow = (label: String, journal: String, ending: Bool, status: Int32, raws: [String], + calls: (ScriptFixture) -> [String], undone: (inout RuntimeState, ScriptFixture) throws -> Void) + + /// Greptile 4239597182: saved outputs whose names start as placeholders + /// after eighteen 9s do, one of them with a device UID that holds + /// \u0000 and one with a UID that starts as one after 0 does, as JSON + /// text, and the strings of theirs that start with insomnia-held-. The + /// placeholders of a journal that holds them start with + /// insomnia-held-1-, a start it does not hold. + static let namesAfterEighteenNines = ( + json: #"[{"deviceUID":"d\u0000","name":"insomnia-held-999999999999999999-","volume":0.5,"muted":false},{"deviceUID":"insomnia-held-0-0","name":"insomnia-held-1000000000000000000-0","volume":0.5,"muted":false}]"#, + raws: ["insomnia-held-999999999999999999-", "insomnia-held-0-0", "insomnia-held-1000000000000000000-0"]) + + /// Review40 R40-1: two saved outputs, as JSON text, alike but for their + /// device UIDs and their saveIDs, which hold \u0000. Their placeholders + /// are the same but for their numbers, and both are under saveID. + static let outputsAlikeButForSaveID = #"[{"deviceUID":"A","volume":0.5,"muted":true,"saveID":"a\u0000"},{"deviceUID":"B","volume":0.5,"muted":true,"saveID":"b\u0000"}]"# + + /// Review40 R40-1: a journal with a frozen process the undo at the end + /// clears (of another boot) before two it keeps (pid 0), and an App Nap + /// entry it restores before two it keeps, the kept entries of each + /// array alike but for a string that holds \u0000. Each kept entry + /// moves up one. + static let keptEntriesAlikeButForNUL: String = { + let boot = { (n: Int, b: String) in #"{"pid":\#(n),"startedAt":1760000000,"startedAtMicros":5,"bootSession":"\#(b)"}"# } + return #"{"sleepDisabledByUs":true,"frozenProcesses":[\#(boot(4241, "previous-boot")),\#(boot(0, #"k\u0000"#)),\#(boot(0, #"m\u0000"#))],"appNapOverrides":[{"bundleId":"com.example.y","previous":false},{"bundleId":"a\u0000","previous":true},{"bundleId":"b\u0000","previous":true}]}"# + }() + + /// What backstop.sh logs for an edited copy of the journal whose + /// placeholders are not each once in its own entry under its own key. + private static func notPublished(_ f: ScriptFixture) -> String { + "the edited copy of \(f.state.path) is not a journal the app loads with each string that holds \\u0000 once, in the entry and under the key it had in the journal; not published" + } + + private static func publishedJournalRows() -> [PublishedJournalRow] { + let lit = "0.5000000298023223876953125000000000000001" + let mid = "0.50000002980232238769531250" + let above = "0.500000059604644775390625" + let sleepOff = { (f: ScriptFixture) in "sudo -n \(f.fakePmset) -a disablesleep 0" } + let lowPowerOff = { (f: ScriptFixture) in "sudo -n \(f.fakePmset) -b lowpowermode 0" } + // A long name that holds \u0000 many times, beside U+E000 raw and + // as an escape in either case. + let name = String(repeating: #"\#u{E000}n\u0000\uE000\ue000"#, count: 2000) + let boot = { (n: Int, started: Int, b: String) in #"{"pid":\#(n),"startedAt":\#(started),"startedAtMicros":5,"bootSession":"\#(b)"}"# } + return [ + ("Floats plutil would write back as others", + #"{"sleepDisabledByUs":true,"savedOutputVolume":0.50000002980232239,"savedDisplayBrightness":\#(lit),"savedKeyboardBrightness":0.500000029802322387695312500000000001,"displayRestoredUnderLowPower":\#(mid),"displayRestoreRefused":true,"keyboardRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot-private","keptDisplayReadLit":0.5000000298023223876953125000000000000000000000000000001}"#, + false, 1, [], { [sleepOff($0)] }, { s, _ in s.sleepDisabledByUs = false }), + ("-0 through four edits, with Low Power Mode", + #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"dockerFrozen":true,"frozenProcesses":[{"pid":4343,"startedAt":1760000001}],"savedAudioOutputs":[{"deviceUID":"u\u0000","volume":-0,"muted":true}],"savedOutputVolume":-0.0,"savedDisplayBrightness":-0,"displayRestoreRefused":true,"keptDisplayUnderLowPower":-0e0,"keptDisplayUnderLowPowerBoot":"boot\u0000","keptDisplayReadLit":-0,"appNapOverrides":[{"bundleId":"com.example.y","previous":false}]}"#, + false, 1, [#"u\u0000"#], { [sleepOff($0), lowPowerOff($0), "defaults write com.example.y NSAppSleepDisabled -bool false"] }, + { s, f in s.sleepDisabledByUs = false; s.lowPowerSetByUs = false; s.keptDisplayUnderLowPowerBoot = f.bootUUID; s.appNapOverrides = [] }), + // The second frozen process has no startedAtMicros, so the app + // does not read its bootSession; the scripts compare it, as for + // any entry an older build wrote, and clear it with the first + // as of another boot. + ("\\u0000 in each string the app reads", + #"{"sleepDisabledByUs":true,"savedAudioOutputs":[{"deviceUID":"u\u0000id","name":"n\u0000\\u0000","volume":\#(lit),"muted":true,"saveID":"\u0000"}],"keptDisplayUnderLowPowerBoot":"k\u0000","frozenProcesses":[{"pid":4242,"startedAt":1760000000,"startedAtMicros":5,"bootSession":"b\u0000"},{"pid":4343,"startedAt":1760000001,"bootSession":"c\u0000"}],"appNapOverrides":[{"bundleId":"com.example\u0000x","previous":true},{"bundleId":"com.example.y","previous":false}]}"#, + false, 1, [#"u\u0000id"#, #"n\u0000\\u0000"#, #"\u0000"#, #"k\u0000"#, #"com.example\u0000x"#], + { [sleepOff($0), "defaults write com.example.y NSAppSleepDisabled -bool false"] }, + { s, _ in s.sleepDisabledByUs = false; s.frozenProcesses = []; s.appNapOverrides.removeLast() }), + ("\\u0000 in the kept display entry's boot, with Low Power Mode", + #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot\u0000","keptDisplayReadLit":\#(above)}"#, + false, 0, [], { [sleepOff($0), lowPowerOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.lowPowerSetByUs = false; s.keptDisplayUnderLowPowerBoot = f.bootUUID }), + ("\\u0000 in endedSession, the end recorded in the journal", + #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedDisplayBrightness":\#(lit),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot-private","keptDisplayReadLit":\#(above)}"#, + true, 1, [], { [sleepOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.endedSession = try Data(contentsOf: f.session).base64EncodedString() }), + // Review38 R38-1: the app reads the first copy of a key; the + // view holds it alone, and keys the app skips go. + ("\\u0000 in copies of keys and in keys the app skips", + #"{"sleepDisabledByUs":true,"endedSession":"a\u0000","endedSession":"b\u0000","note":"x\u0000","savedAudioOutputs":[{"deviceUID":"d\u0000","deviceUID":"e","volume":0.5,"muted":false,"x\u0000":"\u0000"}],"frozenProcesses":[{"pid":4242,"startedAt":1760000000,"startedAtMicros":5,"bootSession":"b\u0000","bootSession":"c\u0000"}],"savedOutputVolume":0.25}"#, + false, 1, [#"a\u0000"#, #"d\u0000"#], { [sleepOff($0)] }, + { s, _ in s.sleepDisabledByUs = false; s.frozenProcesses = [] }), + // Through the boot given to the kept display entry before Low + // Power Mode goes off, and -0, with which each edit after the + // first starts from the copy read again. + ("a long name that holds \\u0000 and U+E000, with Low Power Mode and -0", + #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot-private","keptDisplayReadLit":\#(above),"savedAudioOutputs":[{"deviceUID":"\uE000\u0000","name":"\#(name)","volume":\#(lit),"muted":true,"saveID":"s\u0000"}],"savedOutputVolume":-0}"#, + false, 1, [#"\uE000\u0000"#, name, #"s\u0000"#], { [sleepOff($0), lowPowerOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.lowPowerSetByUs = false; s.keptDisplayUnderLowPowerBoot = f.bootUUID }), + // Entries taken out of an array and the rest moved up: the third + // frozen process (pid 0, kept) becomes the first, and the App + // Nap entry whose bundle id holds \u0000 the only one. Strings + // that start as placeholders do (insomnia-held-0-) are in the + // journal, so the placeholders start with insomnia-held-1-. + ("frozen processes and App Nap entries taken out and moved up", + #"{"sleepDisabledByUs":true,"note":"insomnia-held-0-0","savedAudioOutputs":[{"deviceUID":"dev","name":"insomnia-held-0-1 n\u0000","volume":0.5,"muted":false}],"frozenProcesses":[\#(boot(4241, 1760000000, #"a\u0000"#)),\#(boot(4242, 1760000000, "previous-boot")),\#(boot(0, 1760000000, #"k\u0000"#)),\#(boot(4243, 1760000000, #"c\u0000"#))],"appNapOverrides":[{"bundleId":"com.example.y","previous":false},{"bundleId":"com.example\u0000x","previous":true}]}"#, + false, 1, [#"insomnia-held-0-1 n\u0000"#, #"k\u0000"#, #"com.example\u0000x"#], + { [sleepOff($0), "defaults write com.example.y NSAppSleepDisabled -bool false"] }, + { s, _ in s.sleepDisabledByUs = false; s.frozenProcesses = [s.frozenProcesses[2]]; s.appNapOverrides.removeFirst() }), + // Greptile 4239597182: the placeholders start with + // insomnia-held-1-, which no name holds, through the boot + // given before Low Power Mode goes off, the undo at the end and + // the end recorded in the journal. savedOutputVolume, which + // only the app restores, stays, so the backstop exits 1, as + // uninstall does with the outputs it lists. + ("names that start as placeholders after eighteen 9s do, with Low Power Mode", + #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot\u0000","keptDisplayReadLit":\#(above),"savedAudioOutputs":\#(namesAfterEighteenNines.json),"savedOutputVolume":0.25}"#, + false, 1, [#"d\u0000"#] + namesAfterEighteenNines.raws, { [sleepOff($0), lowPowerOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.lowPowerSetByUs = false; s.keptDisplayUnderLowPowerBoot = f.bootUUID }), + ("the end in the journal, beside names that start as placeholders after eighteen 9s do", + #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedAudioOutputs":\#(namesAfterEighteenNines.json)}"#, + true, 1, [#"d\u0000"#] + namesAfterEighteenNines.raws, { [sleepOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.endedSession = try Data(contentsOf: f.session).base64EncodedString() }), + // Review40 R40-1: each saveID stays with its own output, which + // the app's state compares, through the undo at the end, the + // boot given before Low Power Mode goes off and the end + // recorded in the journal; and each kept entry moved up keeps + // its own string, the frozen processes with the same pid and + // start. savedOutputVolume stays, so the backstop exits 1, as + // uninstall does with the outputs it lists. + ("two saved outputs alike but for their UIDs and saveIDs", + #"{"sleepDisabledByUs":true,"savedAudioOutputs":\#(outputsAlikeButForSaveID),"savedOutputVolume":0.25}"#, + false, 1, [#"a\u0000"#, #"b\u0000"#], { [sleepOff($0)] }, { s, _ in s.sleepDisabledByUs = false }), + ("two saved outputs alike but for their UIDs and saveIDs, with Low Power Mode", + #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot-private","keptDisplayReadLit":\#(above),"savedAudioOutputs":\#(outputsAlikeButForSaveID),"savedOutputVolume":0.25}"#, + false, 1, [#"a\u0000"#, #"b\u0000"#], { [sleepOff($0), lowPowerOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.lowPowerSetByUs = false; s.keptDisplayUnderLowPowerBoot = f.bootUUID }), + ("the end in the journal, beside two saved outputs alike but for their UIDs and saveIDs", + #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedAudioOutputs":\#(outputsAlikeButForSaveID)}"#, + true, 1, [#"a\u0000"#, #"b\u0000"#], { [sleepOff($0)] }, + { s, f in s.sleepDisabledByUs = false; s.endedSession = try Data(contentsOf: f.session).base64EncodedString() }), + ("kept entries alike but for a string that holds \\u0000, moved up after one taken out", + keptEntriesAlikeButForNUL, false, 1, [#"k\u0000"#, #"m\u0000"#, #"a\u0000"#, #"b\u0000"#], + { [sleepOff($0), "defaults write com.example.y NSAppSleepDisabled -bool false"] }, + { s, _ in s.sleepDisabledByUs = false; s.frozenProcesses.removeFirst(); s.appNapOverrides.removeFirst() }), + ] + } + + /// Checks a journal a run published from `row`: no NUL byte and no + /// placeholder, each string the run keeps as its own text, and the + /// app's state, bit for bit, as before but for what the run undid or + /// recorded. + private func checkPublishedJournal(_ row: PublishedJournalRow, _ f: ScriptFixture) throws { + let published = try Data(contentsOf: f.state) + let text = String(decoding: published, as: UTF8.self) + XCTAssertFalse(text.contains("\u{0}"), "\(row.label): \(text)") + XCTAssertEqual(text.components(separatedBy: "insomnia-held-").count, row.raws.filter { $0.contains("insomnia-held-") }.count + 1, "\(row.label): \(text)") + for raw in row.raws { + XCTAssertTrue(text.contains("\"\(raw)\""), "\(row.label): \(raw.prefix(80)) in \(text.prefix(2000))") + } + var expected = try Store.makeDecoder().decode(RuntimeState.self, from: Data(row.journal.utf8)) + try row.undone(&expected, f) + let after = try Store.makeDecoder().decode(RuntimeState.self, from: published) + XCTAssertEqual(after, expected, "\(row.label): \(text.prefix(2000))") + XCTAssertEqual(try Store.makeEncoder().encode(after), try Store.makeEncoder().encode(expected), "\(row.label), bit for bit: \(text.prefix(2000))") + } + + /// Review35 R35-1, past the reader: whole backstop runs on journals the + /// app loads whose Floats plutil would write back as others (38 digits, + /// the midpoint of two Floats and digits beside it, -0 in each + /// spelling) and whose strings the app reads hold \u0000. Each journal + /// the run publishes (the end record in the journal, the kept display + /// entry's record given this boot before Low Power Mode goes off, the + /// undo at the end, which can take four edits) decodes, bit for bit, to + /// what the app read before, but for what the run undid or recorded: + /// sleep, Low Power Mode, a frozen process of another boot, an App Nap + /// entry restored, this boot, the end. An App Nap entry whose bundle id + /// holds \u0000 is kept with no defaults call, and nothing is left + /// beside the journal. Review38 R38-1: each string that holds \u0000 + /// the run keeps is written as its own text, of any length, beside + /// U+E000 in every form, the first copy of a key, in entries that + /// moved up an array after others were taken out; no string of one + /// taken out comes back. Review40 R40-1: each stays in its own entry + /// where entries are alike but for it, moved up or not. + func testPublishedJournalsKeepTheFloatsAndStringsTheAppReads() async throws { + let rows = Self.publishedJournalRows() + // One fixture per journal; the runs go several at a time. + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + for row in rows { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try f.writeState(row.journal) + if row.ending { + // session.json, with the app gone, cannot be removed, and + // ended-session.json holds a record that cannot be replaced. + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try setImmutable(f.session, true) + try "{}".write(to: f.endedSession, atomically: true, encoding: .utf8) + try setImmutable(f.endedSession, true) + } + } + + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.backstop) }) + + for (row, (f, r)) in zip(rows, zip(fixtures, results)) { + XCTAssertEqual(r.status, row.status, row.label + r.stderr + f.log()) + XCTAssertEqual(f.calls().filter { call in ["sudo", "defaults", "kill"].contains { call.hasPrefix($0 + " ") } }, row.calls(f), row.label) + XCTAssertFalse(f.log().contains("could not publish"), "\(row.label): \(f.log())") + XCTAssertEqual(try f.contents(of: f.home).filter { $0.hasPrefix(".state.json") }, [], row.label) + try checkPublishedJournal(row, f) + } + } + + /// Review35 R35-1, the same journals through uninstall.sh, which + /// publishes a journal only through the backstop it runs. Each + /// uninstall undoes what the backstop alone undoes, exits as it does, + /// and leaves a journal that decodes, bit for bit, to the same. Where + /// an entry only the app restores stays, the uninstall stops with the + /// app, the agent and the sudoers rule in place; where only the kept + /// display entry stays, it removes them. No placeholder of the view's + /// shows. Every row but the one that records a session's end in the + /// journal, which needs a valid session. The runs go several at a + /// time, one fixture each. + func testAnUninstallPublishesTheFloatsAndStringsTheAppReads() async throws { + let rows = Self.publishedJournalRows().filter { !$0.ending } + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + for row in rows { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try f.installMachinery() + try f.writeConfig(#"{"agentList":[]}"#) + try f.writeState(row.journal) + } + + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.uninstall) }) + + for (row, (f, r)) in zip(rows, zip(fixtures, results)) { + let label = "\(row.label): \(r.stdout.prefix(4000)) \(r.stderr.prefix(4000)) \(f.log().prefix(4000))" + XCTAssertEqual(r.status, row.status, label) + XCTAssertFalse(r.stderr.contains("malformed") || f.log().contains("malformed"), label) + XCTAssertFalse(f.log().contains("could not publish"), label) + // No placeholder of the view's shows; the journal's own strings + // that start like one may, as JSON text in quotes or, names + // that hold no \u0000, as themselves. The longest go first. + var shown = r.stdout + r.stderr + let owns = row.journal.components(separatedBy: "\"").filter { $0.hasPrefix("insomnia-held-") }.sorted { $0.count > $1.count } + for own in owns { + shown = shown.replacingOccurrences(of: "\"\(own)\"", with: "").replacingOccurrences(of: own, with: "") + } + XCTAssertFalse(shown.contains("insomnia-held-"), label) + let undoing = f.calls().filter { call in ["sudo -n ", "defaults write ", "defaults delete ", "kill "].contains { call.hasPrefix($0) } } + XCTAssertEqual(undoing, row.calls(f), label) + for url in [f.app, f.plist, f.sudoers] { + XCTAssertEqual(f.exists(url), row.status != 0, "\(url.lastPathComponent): \(label)") + } + XCTAssertEqual(try f.contents(of: f.home).filter { $0.hasPrefix(".state.json") }, [], row.label) + try checkPublishedJournal(row, f) + } + } + + /// Review38 R38-1: a publication of a journal whose strings hold + /// \u0000 that does not have each back once, in its own entry under its + /// own key, keeps what is owed. PLUTIL is a fake that runs plutil and then, after one edit + /// (a -replace of `key`), changes the copy with sed or fails. The undo + /// at the end, over the journal whose entries move up + /// (publishedJournalRows), placeholders insomnia-held-1-0 (the name) + /// to -4: a placeholder lost, written twice, put back where its entry + /// was taken out, moved to another string the app reads, written with + /// an escape or with more after it, a level the app reads as another + /// Float (0.25), plutil failing. Each run says it could not publish + /// and exits 1, and state.json keeps its bytes, so every entry stays + /// for the next run. The kept display entry's boot before Low Power + /// Mode goes off: the mode stays on, and the undo at the end still + /// publishes the journal with that record as it was. The end recorded + /// in the journal: it goes to the next place, and endedSession stays. + /// Greptile 4239597182: the same two beside names that start as + /// placeholders after eighteen 9s do, the placeholders starting with + /// insomnia-held-1-. GREP failing, so that no placeholder can be had: + /// the journal is malformed, nothing is undone and state.json keeps + /// its bytes. + func testAPublicationThatDoesNotPutEachStringBackKeepsWhatIsOwed() async throws { + let above = "0.500000059604644775390625" + let moved = try XCTUnwrap(Self.publishedJournalRows().first { $0.label == "frozen processes and App Nap entries taken out and moved up" }) + let lowPower = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot\u0000","keptDisplayReadLit":\#(above),"savedAudioOutputs":[{"deviceUID":"d\u0000","volume":0.5,"muted":false}]}"# + let ending = #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedAudioOutputs":[{"deviceUID":"d\u0000","volume":0.5,"muted":false}]}"# + let appNap = #"{"sleepDisabledByUs":true,"appNapOverrides":[{"bundleId":"a\u0000","previous":true}]}"# + let names = Self.namesAfterEighteenNines + let lowPowerNames = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot\u0000","keptDisplayReadLit":\#(above),"savedAudioOutputs":\#(names.json)}"# + let endingNames = #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedAudioOutputs":\#(names.json)}"# + let sed = "/usr/bin/sed -i '' " + // What the fake does after the undo's edit of frozenProcesses. + let undo: [(label: String, tamper: String)] = [ + ("a placeholder lost", sed + #"-e 's/"insomnia-held-1-2"/"k"/' "$file""#), + ("a placeholder twice", sed + #"-e 's/"dev"/"insomnia-held-1-2"/' "$file""#), + ("the placeholder of an entry taken out", sed + #"-e 's/"dev"/"insomnia-held-1-1"/' "$file""#), + ("a placeholder moved to another string", sed + #"-e 's/"insomnia-held-1-0"/"n"/' -e 's/"dev"/"insomnia-held-1-0"/' "$file""#), + ("a placeholder's start written with an escape", sed + #"-e 's/"dev"/"\\u0069nsomnia-held-1-9"/' "$file""#), + ("a placeholder with more after it", sed + #"-e 's/"dev"/"insomnia-held-1-2x"/' "$file""#), + ("a level the app reads as another Float", sed + #"-e 's/"volume":0.5/"volume":0.25/' "$file""#), + ("plutil failing", "exit 1"), + ] + // (label, journal, the key of the edit the fake follows, what it + // does then, run on "$file", whether GREP fails) + let failing = { (label: String, journal: String, key: String, tamper: String, grepFails: Bool) in (label, journal, key, tamper, grepFails) } + let rows = undo.map { failing("the undo at the end: " + $0.label, moved.journal, "frozenProcesses", $0.tamper, false) } + [ + failing("the kept display entry's boot", lowPower, "keptDisplayUnderLowPowerBoot", sed + #"-e 's/"insomnia-held-0-1"/"d"/' "$file""#, false), + failing("the end in the journal", ending, "endedSession", sed + #"-e 's/"insomnia-held-0-1"/"d"/' "$file""#, false), + failing("the kept display entry's boot, beside names that start as placeholders after eighteen 9s do", lowPowerNames, + "keptDisplayUnderLowPowerBoot", sed + #"-e 's/"insomnia-held-1-1"/"d"/' "$file""#, false), + failing("the end in the journal, beside names that start as placeholders after eighteen 9s do", endingNames, + "endedSession", sed + #"-e 's/"insomnia-held-1-1"/"d"/' "$file""#, false), + failing("no placeholder can be had", appNap, "", "", true), + ] + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + for row in rows { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try f.writeState(row.1) + if row.2 == "endedSession" { + // As in publishedJournalRows: the end can go to neither + // session.json's removal nor ended-session.json. + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try setImmutable(f.session, true) + try "{}".write(to: f.endedSession, atomically: true, encoding: .utf8) + try setImmutable(f.endedSession, true) + } + let plutil = f.root.appendingPathComponent("plutil") + try """ + #!/bin/bash + file="${!#}" + /usr/bin/plutil "$@" || exit $? + if [[ "${1:-}" == -replace && "${2:-}" == '\(row.2)' ]]; then + \(row.3) + fi + exit 0 + + """.write(to: plutil, atomically: true, encoding: .utf8) + let grep = f.root.appendingPathComponent("grep") + try """ + #!/bin/bash + if [[ "${1:-}" == -a && "${2:-}" == -o ]]; then exit 2; fi + exec /usr/bin/grep "$@" + + """.write(to: grep, atomically: true, encoding: .utf8) + for fake in [plutil, grep] { + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fake.path) + } + var text = try ScriptFixture.replaceOnce(try String(contentsOf: f.backstop, encoding: .utf8), "\nPLUTIL=/usr/bin/plutil\n", with: "\nPLUTIL='\(plutil.path)'\n") + if row.4 { + text = try ScriptFixture.replaceOnce(text, "\nGREP=/usr/bin/grep\n", with: "\nGREP='\(grep.path)'\n") + } + try text.write(to: f.backstop, atomically: true, encoding: .utf8) + } - let ur = try u.run(u.uninstall, ["--purge"]) + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.backstop) }) - XCTAssertNotEqual(ur.status, 0, json) - XCTAssertTrue(u.exists(u.app), json) - XCTAssertTrue(ur.stderr.contains(problem), "\(json): \(ur.stderr)") + for (row, (f, r)) in zip(rows, zip(fixtures, results)) { + let label = row.0 + let log = f.log() + let notPublished = Self.notPublished(f) + let sleepOff = "sudo -n \(f.fakePmset) -a disablesleep 0" + let undoing = f.calls().filter { call in ["sudo", "defaults", "kill"].contains { call.hasPrefix($0 + " ") } } + XCTAssertEqual(r.status, 1, "\(label): \(r.stderr) \(log)") + XCTAssertEqual(try f.contents(of: f.home).filter { $0.hasPrefix(".state.json") }, [], label) + let published = try String(contentsOf: f.state, encoding: .utf8) + let owned = row.1.contains(names.json) ? names.raws : [] + switch row.2 { + case "frozenProcesses": + XCTAssertEqual(published, row.1, label) + XCTAssertTrue(log.contains("could not publish the updated journal to \(f.state.path); previous journal kept, will retry"), "\(label): \(log)") + let anotherFloat = "the edited copy of \(f.state.path) holds a level the app would read as another Float than the journal's; not published" + XCTAssertEqual(log.contains(notPublished), row.3 != "exit 1" && !row.3.contains("0.25"), "\(label): \(log)") + XCTAssertEqual(log.contains(anotherFloat), row.3.contains("0.25"), "\(label): \(log)") + XCTAssertEqual(undoing, [sleepOff, "defaults write com.example.y NSAppSleepDisabled -bool false"], label) + case "keptDisplayUnderLowPowerBoot": + XCTAssertTrue(log.contains(notPublished), "\(label): \(log)") + XCTAssertTrue(log.contains("could not publish this boot for the kept display entry's record to \(f.state.path); Low Power Mode left on"), "\(label): \(log)") + XCTAssertEqual(undoing, [sleepOff], label) + try checkPublishedJournal((label, row.1, false, 1, [#"boot\u0000"#, #"d\u0000"#] + owned, { _ in [] }, { s, _ in s.sleepDisabledByUs = false }), f) + case "endedSession": + XCTAssertTrue(log.contains(notPublished), "\(label): \(log)") + XCTAssertFalse(log.contains("(endedSession) instead"), "\(label): \(log)") + XCTAssertTrue(log.contains("its end is recorded in"), "\(label): \(log)") + XCTAssertEqual(undoing, [sleepOff], label) + try checkPublishedJournal((label, row.1, false, 1, [#"e\u0000"#, #"d\u0000"#] + owned, { _ in [] }, { s, _ in s.sleepDisabledByUs = false }), f) + default: + XCTAssertEqual(published, row.1, label) + XCTAssertTrue(log.contains("appNapOverrides[0].bundleId holds \\u0000, which plutil cannot hold, and no placeholder for it could be had here, so what the app reads is not known here"), "\(label): \(log)") + XCTAssertEqual(undoing, [], label) + } } } - /// record_text_problems as it is in each script, run on its own over - /// exact bytes: what it prints, or nothing. - private func recordTextProblems(_ inputs: [(label: String, bytes: Data)], script: String = "backstop.sh") throws -> [String: String] { - let f = try ScriptFixture() - defer { f.destroy() } - let runner = f.root.appendingPathComponent("record-text-problems.sh") - try ("set -euo pipefail\n" + Self.recordTextProblemsSource(script) + "\nrecord_text_problems \"$1\"\n") - .write(to: runner, atomically: true, encoding: .utf8) - var printed: [String: String] = [:] - for (i, input) in inputs.enumerated() { - let file = f.root.appendingPathComponent("input.\(i)") - try input.bytes.write(to: file) - let r = try f.run(runner, [file.path]) - XCTAssertEqual(r.status, 0, "\(input.label): \(r.stderr)") - XCTAssertEqual(r.stderr, "", input.label) - printed[input.label] = r.stdout + /// Review40 R40-1: a publication of a copy in which two strings that + /// hold \u0000, under the same key of two entries of one array alike + /// but for them, changed places keeps what is owed. PLUTIL is a fake + /// that runs plutil and then, after an edit of one of `keys`, swaps + /// the two placeholders with sed. The saveIDs of two saved outputs + /// (outputsAlikeButForSaveID) through the undo at the end, the kept + /// display entry's boot before Low Power Mode goes off, and the end + /// recorded in the journal; the boots of two frozen processes and the + /// bundle ids of two App Nap entries the undo keeps, each pair moved up + /// after an entry it takes out (keptEntriesAlikeButForNUL); and with + /// Low Power Mode kept on, the boot and the undo at the end both. No + /// copy with a swap is published. The undo at the end exits 1 with + /// state.json's bytes kept and every entry there for the next run; + /// after the boot or the end, Low Power Mode stays on or endedSession + /// stays, and the undo at the end publishes the journal with each + /// saveID still with its own output. + func testAPublicationThatMovesAStringToAnotherEntryKeepsWhatIsOwed() async throws { + let above = "0.500000059604644775390625" + let outputs = Self.outputsAlikeButForSaveID + let sleep = #"{"sleepDisabledByUs":true,"savedAudioOutputs":\#(outputs)}"# + let lowPower = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":true,"savedDisplayBrightness":\#(above),"displayRestoreRefused":true,"keptDisplayUnderLowPower":\#(above),"keptDisplayUnderLowPowerBoot":"boot-private","keptDisplayReadLit":\#(above),"savedAudioOutputs":\#(outputs)}"# + let ending = #"{"sleepDisabledByUs":true,"endedSession":"e\u0000","savedAudioOutputs":\#(outputs)}"# + // Swaps placeholders insomnia-held-0-a and insomnia-held-0-b in + // "$file", which holds no "swapped". + let swap = { (a: Int, b: Int) in + #"/usr/bin/sed -i '' -e 's/"insomnia-held-0-\#(a)"/"swapped"/' -e 's/"insomnia-held-0-\#(b)"/"insomnia-held-0-\#(a)"/' -e 's/"swapped"/"insomnia-held-0-\#(b)"/' "$file""# } - return printed - } + // The placeholders are numbered in the journal's order, with + // endedSession's first. + let rows: [(label: String, journal: String, keys: [String], swap: String)] = [ + ("the undo at the end: two saveIDs", sleep, ["sleepDisabledByUs"], swap(0, 1)), + ("the undo at the end: the boots of two frozen processes kept and moved up", Self.keptEntriesAlikeButForNUL, ["frozenProcesses"], swap(0, 1)), + ("the undo at the end: the bundle ids of two App Nap entries kept and moved up", Self.keptEntriesAlikeButForNUL, ["appNapOverrides"], swap(2, 3)), + ("the kept display entry's boot: two saveIDs", lowPower, ["keptDisplayUnderLowPowerBoot"], swap(0, 1)), + ("the end in the journal: two saveIDs", ending, ["endedSession"], swap(1, 2)), + ("Low Power Mode kept on: the boot and the undo at the end, two saveIDs", lowPower, ["keptDisplayUnderLowPowerBoot", "sleepDisabledByUs"], swap(0, 1)), + ] + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + for row in rows { + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try f.writeState(row.journal) + if row.keys.contains("endedSession") { + // As in publishedJournalRows: the end can go to neither + // session.json's removal nor ended-session.json. + try f.writeSession(endsAt: Date(timeIntervalSinceNow: 3600)) + try setImmutable(f.session, true) + try "{}".write(to: f.endedSession, atomically: true, encoding: .utf8) + try setImmutable(f.endedSession, true) + } + let plutil = f.root.appendingPathComponent("plutil") + try """ + #!/bin/bash + file="${!#}" + /usr/bin/plutil "$@" || exit $? + if [[ "${1:-}" == -replace ]]; then + case "${2:-}" in + \(row.keys.joined(separator: "|"))) + \(row.swap) || exit 1 + ;; + esac + fi + exit 0 - private static func recordTextProblemsSource(_ script: String) throws -> String { - let text = try String(contentsOf: ScriptFixture.productionScripts.appendingPathComponent(script), encoding: .utf8) - let lines = text.components(separatedBy: "\n") - let start = try XCTUnwrap(lines.firstIndex(of: "record_text_problems() { # file"), script) - let end = try XCTUnwrap(lines[start...].firstIndex(of: "}"), script) - return lines[start...end].joined(separator: "\n") - } + """.write(to: plutil, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: plutil.path) + let text = try ScriptFixture.replaceOnce(try String(contentsOf: f.backstop, encoding: .utf8), "\nPLUTIL=/usr/bin/plutil\n", with: "\nPLUTIL='\(plutil.path)'\n") + try text.write(to: f.backstop, atomically: true, encoding: .utf8) + } - /// The two scripts carry the same reader, comment and all. - func testBothScriptsReadTheRecordsTheSameWay() throws { - func withComment(_ script: String) throws -> String { - let text = try String(contentsOf: ScriptFixture.productionScripts.appendingPathComponent(script), encoding: .utf8) - let start = try XCTUnwrap(text.range(of: "# Prints one line per way the app's records about a kept display entry would"), script) - let end = try XCTUnwrap(text.range(of: "\n}\n", range: start.upperBound.. Data { Data(s.utf8) } - let cases: [(label: String, bytes: Data, prints: String, appReads: Bool)] = [ - ("no records", utf8(#"{"sleepDisabledByUs":true}"#), "", true), - ("empty object", utf8("{ }"), "", true), - ("UTF-8 byte order mark", bom + utf8(#"{"keptDisplayReadLit":0.8}"#), "", true), - ("UTF-8 byte order mark, too small", bom + utf8(#"{"keptDisplayReadLit":1e-400}"#), - "keptDisplayReadLit is 1e-400, too small a number for the app to read\n", false), - ("UTF-16 with a byte order mark", Data([0xFF, 0xFE]) + #"{"keptDisplayReadLit":0.8}"#.data(using: .utf16LittleEndian)!, - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", true), - ("UTF-16 without a byte order mark", #"{"keptDisplayReadLit":0.8}"#.data(using: .utf16LittleEndian)!, - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", true), - ("UTF-16 without a byte order mark, hiding a key", - "{\"a\":\"\u{2278}\u{222C}\u{2271}\u{203A}\u{205B}\",\"keptDisplayReadLit\":1e-400,\"b\":\"\u{2C5D}\u{2220}\u{2263}\u{203A}\u{7822}\"}" - .data(using: .utf16LittleEndian)!, - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", false), - ("UTF-16 without records", #"{"sleepDisabledByUs":true}"#.data(using: .utf16LittleEndian)!, "", true), - ("NUL byte in a string", utf8("{\"keptDisplayReadLit\":0.8,\"a\":\"x\u{0}y\"}"), - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", true), - ("comma before the end", utf8(#"{"keptDisplayReadLit":0.8,}"#), "", true), - ("whitespace everywhere", utf8("\n{ \"a\" :\t[ 1 ,2 ] ,\r\n \"keptDisplayReadLit\"\n:\n0.8\n}\n"), "", true), - ("escaped letter, upper hex", utf8(#"{"kept\#(b)u0044isplayReadLit":1e-400}"#), - "keptDisplayReadLit is 1e-400, too small a number for the app to read\n", false), - ("escaped letter, lower hex", utf8(#"{"keptDisplayRead\#(b)u004cit":1e39}"#), - "keptDisplayReadLit is 1e39, too large a number for the app to read\n", false), - ("escaped letter, valid value", utf8(#"{"keptDisplayReadL\#(b)u0069t":0.8}"#), "", true), - ("other escapes in keys", utf8(#"{"a\#(b)"\#(b)\#(b)\#(b)/\#(b)b\#(b)f\#(b)n\#(b)r\#(b)t\#(b)u00e9\#(b)ud83d\#(b)ude00":1}"#), "", true), - ("a key with a \\x escape", utf8(#"{"kept\#(b)x44isplayReadLit":0.8}"#), - "a key in state.json has an escape JSON does not have, so its records about a kept display entry cannot be checked\n", false), - ("unquoted key", utf8(#"{keptDisplayReadLit:0.8}"#), - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", false), - ("block comment", utf8(#"{"a":1,/* c */"keptDisplayReadLit":0.8}"#), - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", false), - ("line comment", utf8("{\"a\":1, // c\n\"keptDisplayReadLit\":0.8}"), - "the top level of state.json cannot be followed here, so its records about a kept display entry cannot be checked\n", false), - ("escaped backslash in a value", utf8(#"{"name":"Headset \#(b)\#(b)u0041","uid":"\#(b)\#(b)"}"#), "", true), - ("escape in a value", utf8(#"{"name":"Headset \#(b)u0041 \#(b)"keptDisplayReadLit\#(b)":1e-400"}"#), "", true), - ("nested copies", utf8(#"{"keptDisplayReadLit":0.8,"a":{"keptDisplayReadLit":1e-400,"b":[["keptDisplayReadLit",{"keptDisplayReadLit":0.7}]]}}"#), "", true), - ("brackets in nested strings", utf8(#"{"a":{"b":"}]","c":["{[",{"d":"\#(b)"}"}]},"keptDisplayReadLit":1e-400}"#), - "keptDisplayReadLit is 1e-400, too small a number for the app to read\n", false), - ("read twice", utf8(#"{"keptDisplayReadLit":0.8,"keptDisplayReadL\#(b)u0069t":0.8}"#), - "keptDisplayReadLit is in the top level of state.json 2 times; the app reads the first and plutil the last\n", true), - ("boot read twice", utf8(#"{"keptDisplayUnderLowPowerBoot":"a","keptDisplayUnderLowPowerBoot":null}"#), - "keptDisplayUnderLowPowerBoot is in the top level of state.json 2 times; the app reads the first and plutil the last\n", true), - ("zero forms", utf8(#"{"keptDisplayReadLit":-0,"keptDisplayUnderLowPower":0e-400}"#), "", true), - ("leading zero", utf8(#"{"keptDisplayReadLit":01}"#), - "keptDisplayReadLit is written as 01, which the app does not read as a number\n", false), - ] - let printed = try recordTextProblems(cases.map { ($0.label, $0.bytes) }) - let printedByUninstall = try recordTextProblems(cases.map { ($0.label, $0.bytes) }, script: "uninstall.sh") - for c in cases { - XCTAssertEqual(printed[c.label], c.prints, c.label) - XCTAssertEqual(printedByUninstall[c.label], c.prints, c.label) - let reads = (try? Store.makeDecoder().decode(RuntimeState.self, from: c.bytes)) != nil - XCTAssertEqual(reads, c.appReads, "the app's decoder on \(c.label)") + /// Review38 R38-1: uninstall names what stays in a journal whose + /// strings hold \u0000 as the journal writes them. An output device, + /// by its name or else its UID, is shown as JSON text in quotes with a + /// note on \u0000; the frozen processes and App Nap entries as JSON, + /// each such string as its own text. No placeholder of the view's + /// shows, a U+E000 written as an escape stays an escape, and a real + /// U+E000 in a string without \u0000 shows as itself. + func testUninstallShowsAStringThatHoldsNULAsTheJournalWritesIt() throws { + try fx.installMachinery() + let json = #"{"sleepDisabledByUs":false,"frozenProcesses":[{"pid":0,"startedAt":1760000000,"startedAtMicros":5,"bootSession":"k\u0000\uE000"}],"savedAudioOutputs":[{"deviceUID":"usb\u0000","name":"USB \u0000 Headset","volume":0.3,"muted":false},{"deviceUID":"d\u0000","volume":0.5,"muted":false},{"deviceUID":"e","name":"\#u{E000} Speakers","volume":0.5,"muted":false}],"appNapOverrides":[{"bundleId":"com.example\u0000x","previous":true}]}"# + try fx.writeState(json) + + let r = try fx.run(fx.uninstall, []) + + XCTAssertNotEqual(r.status, 0) + XCTAssertTrue(fx.exists(fx.app)) + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), json) + let note = #" (as JSON text in state.json, where \u0000 is a NUL character)"# + XCTAssertTrue(r.stderr.contains(#""USB \u0000 Headset""# + note + " is still muted from a lid close"), r.stderr) + XCTAssertTrue(r.stderr.contains(#""d\u0000""# + note + " is still muted from a lid close"), r.stderr) + XCTAssertTrue(r.stderr.contains("\u{E000} Speakers is still muted from a lid close"), r.stderr) + XCTAssertTrue(r.stderr.contains(#"frozen processes are still journaled: "#), r.stderr) + XCTAssertTrue(r.stderr.contains(#""k\u0000\uE000""#), r.stderr) + XCTAssertTrue(r.stderr.contains(#"App Nap settings (NSAppSleepDisabled) are not put back: "#), r.stderr) + XCTAssertTrue(r.stderr.contains(#""com.example\u0000x""#), r.stderr) + for out in [r.stdout, r.stderr] { + XCTAssertFalse(out.contains("insomnia-held-") || out.contains("\u{0}"), out) } + XCTAssertEqual(r.stderr.components(separatedBy: "\u{E000}").count, 2, r.stderr) + } + + /// Greptile 4239597182: uninstall shows names of saved outputs that + /// start as placeholders after eighteen 9s do as themselves, and an + /// output with no name by its device UID that holds \u0000, as the + /// journal writes it, once. At 23bfb75 the placeholder for the first + /// output's UID, which also holds \u0000, was + /// insomnia-held-1000000000000000000-0, the second output's name, + /// which was then shown as that UID's text. + func testUninstallShowsNamesThatStartAsPlaceholdersAfterEighteenNinesAsThemselves() throws { + try fx.installMachinery() + let outputs = Self.namesAfterEighteenNines.json.dropLast() + #",{"deviceUID":"e\u0000","volume":0.5,"muted":false}]"# + let json = #"{"sleepDisabledByUs":false,"savedAudioOutputs":\#(outputs)}"# + try fx.writeState(json) + + let r = try fx.run(fx.uninstall, []) + + XCTAssertNotEqual(r.status, 0) + XCTAssertTrue(fx.exists(fx.app)) + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), json) + let note = #" (as JSON text in state.json, where \u0000 is a NUL character)"# + XCTAssertTrue(r.stderr.contains("insomnia-held-999999999999999999- is still muted from a lid close"), r.stderr) + XCTAssertTrue(r.stderr.contains("insomnia-held-1000000000000000000-0 is still muted from a lid close"), r.stderr) + XCTAssertEqual(r.stderr.components(separatedBy: #""e\u0000""# + note + " is still muted from a lid close").count, 2, r.stderr) + XCTAssertFalse(r.stderr.contains(#""d\u0000""#), r.stderr) + XCTAssertFalse(r.stderr.contains("insomnia-held-1-"), r.stderr) } /// The same records in a form the app reads do not stop the uninstall: /// it completes past the kept entry, and state.json stays byte for byte /// with them, even with --purge. - func testUninstallCompletesPastValidKeptDisplayRecordsAndKeepsThem() throws { + func testUninstallCompletesPastValidKeptDisplayRecordsAndKeepsThem() async throws { + // One fixture per journal; the runs go several at a time. + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } for records in Self.validKeptDisplayRecords { - let json = Self.keptDisplayJournal(records, ours: false) - let f = try ScriptFixture() - defer { f.destroy() } + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) try f.installMachinery() try f.writeConfig(#"{"agentList":[]}"#) - try f.writeState(json) + try f.writeState(Self.keptDisplayJournal(records, ours: false)) + } - let r = try f.run(f.uninstall, ["--purge"]) + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.uninstall, ["--purge"]) }) + for (records, (f, r)) in zip(Self.validKeptDisplayRecords, zip(fixtures, results)) { + let json = Self.keptDisplayJournal(records, ours: false) XCTAssertEqual(r.status, 0, json + r.stderr + r.stdout) XCTAssertFalse(f.exists(f.plist), json) XCTAssertFalse(f.exists(f.app), json) @@ -3158,16 +6590,19 @@ final class RecoveryScriptTests: XCTestCase { // MARK: - Microsecond identity through the app binary - private func writeMicrosecondEntry(pid: Int, started: Int, micros: Int, boot: String? = nil, extra: String = "") throws { - try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) - try fx.writeState(""" + /// On `f`, or on `fx` when nil. + private func writeMicrosecondEntry(pid: Int, started: Int, micros: Int, boot: String? = nil, extra: String = "", on f: ScriptFixture? = nil) throws { + let target: ScriptFixture = f ?? fx + try target.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + try target.writeState(""" {"sleepDisabledByUs":false,"lowPowerSetByUs":false,"dockerFrozen":true, - "frozenProcesses":[{"pid":\(pid),"startedAt":\(started),"startedAtMicros":\(micros),"bootSession":"\(boot ?? fx.bootUUID)"\(extra)}]} + "frozenProcesses":[{"pid":\(pid),"startedAt":\(started),"startedAtMicros":\(micros),"bootSession":"\(boot ?? target.bootUUID)"\(extra)}]} """) } - private func onlyFrozenEntry() throws -> [String: Any]? { - (try fx.stateJSON()["frozenProcesses"] as? [[String: Any]])?.first + private func onlyFrozenEntry(on f: ScriptFixture? = nil) throws -> [String: Any]? { + let target: ScriptFixture = f ?? fx + return (try target.stateJSON()["frozenProcesses"] as? [[String: Any]])?.first } /// Polls `condition` every 0.05 s; false if it does not hold within @@ -3202,6 +6637,47 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(parent.trimmingCharacters(in: .whitespacesAndNewlines), String(fx.lastPid), "the binary is not a direct child of the backstop shell") } + /// The same entry with each whole number written with a fraction or an + /// exponent (5100.0, 1.789388423e9, 654321.0), which the app reads as + /// those numbers: the binary gets the same line, digits alone + /// (extract_whole). The control is a pid that is not whole (5100.5), + /// which the app does not load: the journal is refused, nothing runs + /// and the file is kept. + func testWholeNumbersWrittenWithAFractionReachTheBinaryAsDigits() throws { + let started = 1_789_388_423 + let json = """ + {"sleepDisabledByUs":false,"lowPowerSetByUs":false,"dockerFrozen":true, + "frozenProcesses":[{"pid":5100.0,"startedAt":1.789388423e9,"startedAtMicros":654321.0,"bootSession":"\(fx.bootUUID)"}]} + """ + let entry = try XCTUnwrap(try Store.decodeState(Data(json.utf8)).frozenProcesses.first) + XCTAssertEqual(entry.pid, 5100) + XCTAssertEqual(entry.identity, ProcessIdentity(startedAt: Int64(started), startedAtMicros: 654_321, bootSession: fx.bootUUID)) + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + try fx.writeState(json) + try fx.psTable([(5100, fx.lstart(started), "T", fx.uid)]) + + let r = try fx.run(fx.backstop) + + XCTAssertEqual(r.status, 0, r.stderr) + XCTAssertEqual(fx.calls(), ["Insomnia --resume-frozen 2 < 5100 \(started) 654321 \(fx.bootUUID)"]) + XCTAssertEqual((try fx.stateJSON()["frozenProcesses"] as? [Any])?.count, 0) + + fx.destroy() + fx = try ScriptFixture() + let half = json.replacingOccurrences(of: "5100.0", with: "5100.5") + XCTAssertThrowsError(try Store.decodeState(Data(half.utf8))) + try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + try fx.writeState(half) + try fx.psTable([(5100, fx.lstart(started), "T", fx.uid)]) + + let refused = try fx.run(fx.backstop) + + XCTAssertNotEqual(refused.status, 0) + XCTAssertEqual(fx.calls(), []) + XCTAssertEqual(try String(contentsOf: fx.state, encoding: .utf8), half) + XCTAssertTrue(fx.log().contains("frozenProcesses[0].pid is 5100.5, which the app's decoder does not read as a whole number it holds there"), fx.log()) + } + /// Microseconds of 0 are an identity too (the key is present), not a /// missing value: the binary is asked, ps is not. func testZeroMicrosecondsStillUsesTheAppBinary() throws { @@ -3270,8 +6746,9 @@ final class RecoveryScriptTests: XCTestCase { /// The answer is checked whole. A word the shell does not know, a known /// word with the wrong exit status, the wrong pid, anything before or /// after the word, or a missing or extra line is not acted on: the entry - /// is kept, nothing is signaled, and the log carries the answer. - func testUnexpectedAppBinaryAnswerKeepsTheEntry() throws { + /// is kept, nothing is signaled, and the log carries the answer. Each + /// answer runs on a fixture of its own, several at a time. + func testUnexpectedAppBinaryAnswerKeepsTheEntry() async throws { let cases: [(output: String, status: Int)] = [ ("5105 bogus\n", 0), ("5105 resumed\n", 1), @@ -3291,24 +6768,30 @@ final class RecoveryScriptTests: XCTestCase { ("5105 resumed\r\n", 0), ("05105 resumed\n", 0), ] + // One fixture per answer; the runs go several at a time. + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } for (output, status) in cases { - let label = "\(output.debugDescription) exit \(status)" - fx.destroy() - fx = try ScriptFixture() - try writeMicrosecondEntry(pid: 5105, started: 1_789_388_423, micros: 2) - try fx.insomniaRaw(output, status: status) + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) + try writeMicrosecondEntry(pid: 5105, started: 1_789_388_423, micros: 2, on: f) + try f.insomniaRaw(output, status: status) + } - let r = try fx.run(fx.backstop) + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.backstop) }) + for ((output, status), (f, r)) in zip(cases, zip(fixtures, results)) { + let label = "\(output.debugDescription) exit \(status)" XCTAssertNotEqual(r.status, 0, label) - XCTAssertEqual(fx.calls().filter { !$0.hasPrefix("Insomnia --resume-frozen") }, [], label) - XCTAssertEqual(try onlyFrozenEntry()?["pid"] as? Int, 5105, label) - XCTAssertEqual(try fx.stateJSON()["dockerFrozen"] as? Bool, true, label) - XCTAssertTrue(fx.exists(fx.session), label) - XCTAssertTrue(fx.log().contains("unexpected answer from \(fx.fakeInsomnia.path) for pid(s) 5105 (exit \(status), output '"), "\(label): \(fx.log())") + XCTAssertEqual(f.calls().filter { !$0.hasPrefix("Insomnia --resume-frozen") }, [], label) + XCTAssertEqual(try onlyFrozenEntry(on: f)?["pid"] as? Int, 5105, label) + XCTAssertEqual(try f.stateJSON()["dockerFrozen"] as? Bool, true, label) + XCTAssertTrue(f.exists(f.session), label) + XCTAssertTrue(f.log().contains("unexpected answer from \(f.fakeInsomnia.path) for pid(s) 5105 (exit \(status), output '"), "\(label): \(f.log())") } // Control characters are logged as spaces, on one line. - XCTAssertTrue(fx.log().contains("output '05105 resumed '"), fx.log()) + let last = try XCTUnwrap(fixtures.last) + XCTAssertTrue(last.log().contains("output '05105 resumed '"), last.log()) } /// Another boot session is settled by the shell: cleared without a @@ -3467,7 +6950,7 @@ final class RecoveryScriptTests: XCTestCase { for run in [{ try self.fx.run(self.fx.backstop) }, { try self.fx.run(wrapper) }] { try writeMicrosecondEntry(pid: 5304, started: 1_789_388_423, micros: 4) - fx.clearCalls() + try fx.clearCalls() let r = try run() XCTAssertEqual(r.status, 0, r.stderr + fx.log()) XCTAssertEqual(fx.calls(), ["Insomnia --resume-frozen 2 < 5304 1789388423 4 \(fx.bootUUID)"]) @@ -3561,7 +7044,7 @@ final class RecoveryScriptTests: XCTestCase { try? FileManager.default.removeItem(at: fx.appInfo) try setUp() try writeMicrosecondEntry(pid: 5310, started: 1_789_388_423, micros: 10) - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.backstop) @@ -3573,13 +7056,63 @@ final class RecoveryScriptTests: XCTestCase { } } + // MARK: - Fixture failures + + /// Review38 R38-2: holdLock, when no probe sees its holder own the + /// lock, has the holder exit and reaps it before it throws, with the + /// holder's exit status in the error. A lock file nothing here can + /// open (mode 000) stops the holder's lockf and each probe at once. + func testHoldLockReapsAHolderThatNeverOwnsTheLock() throws { + try Data().write(to: fx.lock) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: fx.lock.path) + + XCTAssertThrowsError(try fx.holdLock(attempts: 3)) { error in + let text = "\(error)" + let status = text.components(separatedBy: "holder exited ").dropFirst().first.flatMap { Int32($0.prefix { $0.isNumber }) } + XCTAssertNotNil(status, text) + XCTAssertNotEqual(status, 0, text) + XCTAssertNotEqual(status, 75, text) + let probes = text.components(separatedBy: "and was reaped, probes=[").dropFirst().first.map { $0.prefix { $0 != "]" }.split(separator: ",").compactMap { Int32($0.trimmingCharacters(in: .whitespaces)) } } + XCTAssertEqual(probes?.count, 3, text) + XCTAssertFalse(probes?.contains(75) ?? true, text) + } + } + + /// Review38 R38-2: clearing a record counts only a record that is not + /// there as cleared. With the folder that holds it read-only, removing + /// it fails: clearCalls and clearLog (ScriptFixture) and clearCalls + /// (PatchedBackstop) throw and leave the record as it was. With no + /// record, each returns. + func testClearingARecordThatCannotBeRemovedThrowsAndKeepsIt() throws { + let agent = try PatchedBackstop(home: fx.home, dir: fx.root.appendingPathComponent("agent", isDirectory: true)) + let records: [(URL, () throws -> Void)] = [(fx.callsLog, fx.clearCalls), (fx.logFile, fx.clearLog), (agent.dir.appendingPathComponent("calls"), agent.clearCalls)] + let fm = FileManager.default + for (url, clear) in records { + let label = url.path + XCTAssertNoThrow(try clear(), label) + XCTAssertFalse(fm.fileExists(atPath: url.path), label) + XCTAssertNoThrow(try clear(), label) + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "stale\n".write(to: url, atomically: false, encoding: .utf8) + let folder = url.deletingLastPathComponent().path + let mode = try XCTUnwrap(fm.attributesOfItem(atPath: folder)[.posixPermissions] as? Int, label) + try fm.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder) + let failed = Result { try clear() } + try fm.setAttributes([.posixPermissions: mode], ofItemAtPath: folder) + XCTAssertThrowsError(try failed.get(), label) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "stale\n", label) + XCTAssertNoThrow(try clear(), label) + XCTAssertFalse(fm.fileExists(atPath: url.path), label) + } + } + // MARK: - Uninstall locking and interleaving func testUninstallRefusesWhileRecoveryLockIsHeld() throws { try fx.installMachinery() try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) let holder = try fx.holdLock() - defer { holder.stop() } + defer { XCTAssertEqual(holder.stop(), 0, "the lock holder, reaped once its cat read the end of its input: \(holder.problems)") } let r = try fx.run(fx.uninstall) @@ -3647,7 +7180,7 @@ final class RecoveryScriptTests: XCTestCase { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) let holder = try fx.holdLock() - defer { holder.stop() } + defer { XCTAssertEqual(holder.stop(), 0, "the lock holder, reaped once its cat read the end of its input: \(holder.problems)") } let other = fx.root.appendingPathComponent("other.file") try Data().write(to: other) @@ -3708,7 +7241,7 @@ final class RecoveryScriptTests: XCTestCase { fx.setMode("sudo", "hang") XCTAssertNotEqual(try fx.run(fx.backstop).status, 0) fx.setMode("sudo", "ok") - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.backstop) @@ -3797,7 +7330,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(log.contains("recovery stopped"), "the transaction ends right there: \(log)") XCTAssertFalse(log.contains("SIGKILL"), log) fx.setMode("sudo", "ok") - fx.clearCalls() + try fx.clearCalls() let blocked = try fx.run(fx.backstop) XCTAssertEqual(blocked.status, 75, "no new transaction while the command lives: \(blocked.stderr)") XCTAssertEqual(fx.calls(), [], "no mutation outside the lock") @@ -3835,7 +7368,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(try fx.waitUntilLockIsFree(), "the lock is released only when the command exits") XCTAssertEqual(fx.commandEnded(), "released", "ended by the release, not the watchdog") fx.setMode("sudo", "ok") - fx.clearCalls() + try fx.clearCalls() let after = try fx.run(fx.backstop) XCTAssertEqual(after.status, 0, after.stderr + fx.log()) XCTAssertEqual(fx.calls(), ["sudo -n \(fx.fakePmset) -a disablesleep 0", "sudo -n \(fx.fakePmset) -b lowpowermode 0"]) @@ -3885,7 +7418,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(try fx.stateJSON()["sleepDisabledByUs"] as? Bool, true, "ownership retained") XCTAssertFalse(try fx.lockIsFree(), "the supervisor, not the command, holds the lock") fx.setMode("sudo", "ok") - fx.clearCalls() + try fx.clearCalls() let blocked = try fx.run(fx.backstop) XCTAssertEqual(blocked.status, 75, "no new transaction while the command lives: \(blocked.stderr)") XCTAssertEqual(fx.calls(), [], "no mutation outside the lock") @@ -3942,7 +7475,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(sentinel.hasExited) fx.setMode("sudo", "ok") - fx.clearCalls() + try fx.clearCalls() let after = try fx.run(fx.backstop) XCTAssertEqual(after.status, 0, after.stderr + fx.log()) XCTAssertEqual(fx.calls(), ["sudo -n \(fx.fakePmset) -a disablesleep 0", "sudo -n \(fx.fakePmset) -b lowpowermode 0"]) @@ -4051,7 +7584,7 @@ final class RecoveryScriptTests: XCTestCase { for ignoringTerm in [false, true] { try fx.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.backstop, ignoringTerm: ignoringTerm) @@ -4456,7 +7989,7 @@ final class RecoveryScriptTests: XCTestCase { func testInstallFromAZipRunsNoBuildScriptPlantedBesideIt() throws { try fx.prepareInstall() for folder in ["shared-tmp/Insomnia-0.1.0-macos", "shared-tmp/scripts", "repo/Insomnia-0.1.0-macos"] { - fx.clearCalls() + try fx.clearCalls() let unpacked = fx.root.appendingPathComponent(folder, isDirectory: true) try FileManager.default.createDirectory(at: unpacked, withIntermediateDirectories: true) try FileManager.default.copyItem(at: fx.installRedirected, to: unpacked.appendingPathComponent("install.sh")) @@ -4485,7 +8018,7 @@ final class RecoveryScriptTests: XCTestCase { let prebuilt = try fx.writePrebuiltApp() fx.setMode("launchctl", "loaded") for (mode, read) in [("intel-0", "0"), ("intel-missing", "no value")] { - fx.clearCalls() + try fx.clearCalls() fx.setMode("sysctl", mode) let r = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", prebuilt.path], extraEnvironment: ["USER": "tester"]) @@ -4498,7 +8031,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.app)) } - fx.clearCalls() + try fx.clearCalls() let built = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) XCTAssertEqual(built.status, 0, built.stderr + built.stdout) @@ -4520,7 +8053,7 @@ final class RecoveryScriptTests: XCTestCase { for signing in ["adhoc", "developer-id:ABCDE12345"] { fx.setSigning(signing) - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.installRedirected, ["--app", prebuilt.path], extraEnvironment: ["USER": "tester"]) XCTAssertEqual(r.status, 1, "\(signing): \(r.stderr + r.stdout)") @@ -4533,7 +8066,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.sudoers), signing) } - fx.clearCalls() + try fx.clearCalls() let flagged = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", prebuilt.path], extraEnvironment: ["USER": "tester"]) XCTAssertEqual(flagged.status, 0, flagged.stderr + flagged.stdout) @@ -4573,7 +8106,7 @@ final class RecoveryScriptTests: XCTestCase { let sealed = prebuilt.appendingPathComponent("Contents/Resources/backstop.sh") try (String(contentsOf: sealed, encoding: .utf8) + "# edited\n").write(to: sealed, atomically: true, encoding: .utf8) - fx.clearCalls() + try fx.clearCalls() let edited = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", prebuilt.path], extraEnvironment: ["USER": "tester"]) XCTAssertEqual(edited.status, 1, edited.stderr + edited.stdout) @@ -4701,7 +8234,7 @@ final class RecoveryScriptTests: XCTestCase { ] for (name, reason, make) in cases { let bundle = try make(fx) - fx.clearCalls() + try fx.clearCalls() let r = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", bundle.path], extraEnvironment: ["USER": "tester"]) XCTAssertEqual(r.status, 1, "\(name): \(r.stderr + r.stdout)") XCTAssertFalse(fx.calls().contains { $0.hasPrefix("sudo") }, "\(name): \(fx.calls())") @@ -4771,7 +8304,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertFalse(fx.exists(fx.app), "no bundle is installed without an agent that pins it") fx.setMode("launchctl", "loaded") - fx.clearCalls() + try fx.clearCalls() let ok = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) XCTAssertEqual(ok.status, 0, ok.stderr + ok.stdout) @@ -5076,8 +8609,9 @@ final class RecoveryScriptTests: XCTestCase { try fx.writeBundle(at: fx.appsDir.appendingPathComponent(setAside), marker: "set aside by a live run") let holder = try fx.holdLock() - let blocked = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) - holder.stop() + let run = Result { try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) } + XCTAssertEqual(holder.stop(), 0, "the lock holder, reaped once its cat read the end of its input: \(holder.problems)") + let blocked = try run.get() XCTAssertEqual(blocked.status, 75, blocked.stderr + blocked.stdout) XCTAssertEqual(try fx.contents(of: fx.appsDir), ([setAside, dead, live, link, "Insomnia.app"] + unlike).sorted(), "nothing removed outside the lock") @@ -5428,7 +8962,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(try pastedWords(of: printedCommand(in: r.stderr, containing: "launchctl bootstrap")), ["launchctl", "bootstrap", "gui/\(fx.uid)", fx.plist.path]) try FileManager.default.removeItem(at: fx.root.appendingPathComponent("mv.fail")) - fx.clearCalls() + try fx.clearCalls() let rerun = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) XCTAssertEqual(rerun.status, 0, rerun.stderr + rerun.stdout) @@ -5814,7 +9348,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertEqual(plainBuilds, ["swift build -c release", "swift build -c release --show-bin-path"], "\(fx.calls())") XCTAssertFalse(plain.stdout.contains("lid simulation compiled in"), plain.stdout) - fx.clearCalls() + try fx.clearCalls() let simulated = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester", "INSOMNIA_LID_SIMULATION": "1"]) XCTAssertEqual(simulated.status, 0, simulated.stderr + simulated.stdout) let simulatedBuilds = fx.calls().filter { $0.hasPrefix("swift build") } @@ -5825,7 +9359,7 @@ final class RecoveryScriptTests: XCTestCase { XCTAssertTrue(simulated.stdout.contains("lid simulation compiled in (INSOMNIA_LID_SIMULATION=1)"), simulated.stdout) // Any other value is "off": the define is a deliberate opt-in. - fx.clearCalls() + try fx.clearCalls() let other = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester", "INSOMNIA_LID_SIMULATION": "yes"]) XCTAssertEqual(other.status, 0, other.stderr + other.stdout) XCTAssertEqual(fx.calls().filter { $0.hasPrefix("swift build") }.first, "swift build -c release", "\(fx.calls())") @@ -5833,7 +9367,7 @@ final class RecoveryScriptTests: XCTestCase { // A prebuilt bundle is already compiled: asking for the watcher with // --app is refused before anything runs, instead of installing a // build without it. - fx.clearCalls() + try fx.clearCalls() let prebuilt = try fx.writePrebuiltApp() let withApp = try fx.run(fx.installRedirected, ["--allow-unverified-origin", "--app", prebuilt.path], extraEnvironment: ["USER": "tester", "INSOMNIA_LID_SIMULATION": "1"]) XCTAssertEqual(withApp.status, 2, withApp.stderr + withApp.stdout) @@ -5950,7 +9484,7 @@ final class RecoveryScriptTests: XCTestCase { try "trusted".write(to: fx.plist, atomically: true, encoding: .utf8) try fx.writeState(#"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false}"#) let holder = try fx.holdLock() - defer { holder.stop() } + defer { XCTAssertEqual(holder.stop(), 0, "the lock holder, reaped once its cat read the end of its input: \(holder.problems)") } let r = try fx.run(fx.installRedirected, extraEnvironment: ["USER": "tester"]) @@ -6179,9 +9713,14 @@ private final class ScriptFixture { var installRedirected: URL { repoScripts.appendingPathComponent("install.redirected.sh") } /// installRedirected with a 1 s limit for each bounded call. var session: URL { home.appendingPathComponent("session.json") } + var endedSession: URL { home.appendingPathComponent("ended-session.json") } var state: URL { home.appendingPathComponent("state.json") } var config: URL { home.appendingPathComponent("config.json") } var lock: URL { home.appendingPathComponent(".recovery.lock") } + /// Every run's TMPDIR (childEnvironment): uninstall.sh's scratch folder + /// goes here, inside the fixture, not in the shared /tmp. + var tmp: URL { root.appendingPathComponent("tmp", isDirectory: true) } + var alive: URL { home.appendingPathComponent(".app.alive") } /// backstop.sh as install.sh seals it into the bundle. var installedBackstop: URL { app.appendingPathComponent("Contents/Resources/backstop.sh") } /// The writable copy installs before the sealed layout left here. @@ -6203,8 +9742,12 @@ private final class ScriptFixture { private let fm = FileManager.default - init() throws { - root = fm.temporaryDirectory.appendingPathComponent("insomnia-script-tests-\(UUID().uuidString)", isDirectory: true) + /// `productionLimits` keeps the limits backstop.sh and uninstall.sh set + /// on commands, the lock and uninstall's calls in their copies, instead + /// of the 1 s and 5 s (`concurrentRow`). A fixture that fails partway + /// removes what it made before it throws. + init(productionLimits: Bool = false) throws { + root = ProcessTestHome.temporaryDirectory.appendingPathComponent("insomnia-script-tests-\(UUID().uuidString)", isDirectory: true) home = root.appendingPathComponent("home", isDirectory: true) bin = root.appendingPathComponent("bin", isDirectory: true) repoScripts = root.appendingPathComponent("repo/scripts", isDirectory: true) @@ -6212,27 +9755,45 @@ private final class ScriptFixture { app = appsDir.appendingPathComponent("Insomnia.app", isDirectory: true) sudoers = root.appendingPathComponent("etc/sudoers.d/insomnia") callsLog = root.appendingPathComponent("calls.log") - for dir in [home, bin, repoScripts, appsDir] { - try fm.createDirectory(at: dir, withIntermediateDirectories: true) + do { + for dir in [home, bin, repoScripts, appsDir, tmp] { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + // repo/ is a source checkout to install.sh and uninstall.sh: their + // folder is scripts/, with Package.swift one level up. + try "// swift-tools-version: 6.2\n".write(to: root.appendingPathComponent("repo/Package.swift"), atomically: true, encoding: .utf8) + try writeFakes() + try writeScriptCopies(productionLimits: productionLimits) + try bootUUID.write(to: root.appendingPathComponent("boot.uuid"), atomically: true, encoding: .utf8) + } catch { + try? fm.removeItem(at: root) + throw error } - // repo/ is a source checkout to install.sh and uninstall.sh: their - // folder is scripts/, with Package.swift one level up. - try "// swift-tools-version: 6.2\n".write(to: root.appendingPathComponent("repo/Package.swift"), atomically: true, encoding: .utf8) - try writeFakes() - try writeScriptCopies() - try bootUUID.write(to: root.appendingPathComponent("boot.uuid"), atomically: true, encoding: .utf8) } func destroy() { releaseCommand() + // A holder the test left running is stopped and reaped first. One + // that would not exit, or a probe holdLock could not reap, still + // uses this tree, so the tree stays. + for holder in holders where !holder.isSettled { holder.stop() } + let unsettled = holders.filter { !$0.isSettled }.map { "lock holder \($0.child.pid): \($0.problems)" } + unsettledProbes + guard unsettled.isEmpty else { + XCTFail("keeping \(root.path) for processes that did not exit: \(unsettled)") + return + } // A supervisor still waiting to write its status into a FIFO would // wait forever once the FIFO is gone. drainStatusFIFOs(within: 1) + // A test that failed before clearing the flag must not leave its + // temp home behind. + for file in [session, endedSession, state] { try? setImmutable(file, false) } try? fm.removeItem(at: root) } /// The backstop's files in INSOMNIA_HOME: each bounded call's .pid and - /// .rc status files, and the app binary's input and answer directory. + /// .rc status files, each read's .out file, and the app binary's input + /// and answer directory. func backstopFiles() throws -> [String] { try contents(of: home).filter { $0.hasPrefix(".backstop") } } @@ -6282,6 +9843,14 @@ private final class ScriptFixture { .trimmingCharacters(in: .whitespacesAndNewlines) } + /// Whether the read a fake recorded in read.hung.pid is gone: stopped + /// and reaped, so the pid names no process. False when no fake wrote it. + func hungReadIsGone() -> Bool { + guard let text = try? String(contentsOf: root.appendingPathComponent("read.hung.pid"), encoding: .utf8), + let pid = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + return kill(pid, 0) == -1 && errno == ESRCH + } + /// Polls until the recovery lock is free; false after `seconds`. func waitUntilLockIsFree(_ seconds: Double = 15) throws -> Bool { let deadline = Date(timeIntervalSinceNow: seconds) @@ -6353,35 +9922,35 @@ private final class ScriptFixture { // MARK: Scripts - static var productionScripts: URL { - // .../Tests/InsomniaTests/RecoveryScriptTests.swift -> .../scripts - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - .appendingPathComponent("scripts", isDirectory: true) - } + static var productionScripts: URL { ScriptSource.folder } - private func writeScriptCopies() throws { - let src = Self.productionScripts - let backstopText = try String(contentsOf: src.appendingPathComponent("backstop.sh"), encoding: .utf8) - try Self.patch(backstopText, [ + private func writeScriptCopies(productionLimits: Bool) throws { + let backstopLimits = productionLimits ? [:] : [ + "LOCK_TIMEOUT_SECONDS": "1", + "COMMAND_TIMEOUT_SECONDS": "1", + "KILL_GRACE_SECONDS": "1", + ] + try Self.patch(script: "backstop.sh", [ "PMSET": fakePmset, "SUDO": bin.appendingPathComponent("sudo").path, "PS": bin.appendingPathComponent("ps").path, "KILL": bin.appendingPathComponent("kill").path, "SYSCTL": bin.appendingPathComponent("sysctl").path, + "NOTIFYUTIL": bin.appendingPathComponent("notifyutil").path, + "IOREG": bin.appendingPathComponent("ioreg").path, "CHMOD": bin.appendingPathComponent("chmod").path, "INSOMNIA_BIN": fakeInsomnia.path, "INSOMNIA_INFO": appInfo.path, "DEFAULTS": bin.appendingPathComponent("defaults").path, "DATE": bin.appendingPathComponent("date").path, "MV": bin.appendingPathComponent("mv").path, - "LOCK_TIMEOUT_SECONDS": "1", - "COMMAND_TIMEOUT_SECONDS": "1", - "KILL_GRACE_SECONDS": "1", - ]).write(to: backstop, atomically: true, encoding: .utf8) + ].merging(backstopLimits) { $1 }).write(to: backstop, atomically: true, encoding: .utf8) - let uninstallText = try String(contentsOf: src.appendingPathComponent("uninstall.sh"), encoding: .utf8) - try Self.patch(uninstallText, [ + let uninstallLimits = productionLimits ? [:] : [ + "LOCK_TIMEOUT_SECONDS": "1", + "CALL_TIMEOUT_SECONDS": "5", + ] + try Self.patch(script: "uninstall.sh", [ "PGREP": bin.appendingPathComponent("pgrep").path, "OSASCRIPT": bin.appendingPathComponent("osascript").path, "LAUNCHCTL": bin.appendingPathComponent("launchctl").path, @@ -6391,16 +9960,13 @@ private final class ScriptFixture { "KILL": bin.appendingPathComponent("kill").path, "APP": app.path, "SUDOERS": sudoers.path, - "LOCK_TIMEOUT_SECONDS": "1", "QUIT_WAIT_SECONDS": "1", - "CALL_TIMEOUT_SECONDS": "5", - ]).write(to: uninstall, atomically: true, encoding: .utf8) + ].merging(uninstallLimits) { $1 }).write(to: uninstall, atomically: true, encoding: .utf8) // build-app.sh (run by install.sh from $ROOT/scripts): build and // signing go to the fakes. - let buildText = try String(contentsOf: src.appendingPathComponent("build-app.sh"), encoding: .utf8) let buildApp = repoScripts.appendingPathComponent("build-app.sh") - try Self.patch(buildText, [ + try Self.patch(script: "build-app.sh", [ "SWIFT": bin.appendingPathComponent("swift").path, "CODESIGN": bin.appendingPathComponent("codesign").path, ]).write(to: buildApp, atomically: true, encoding: .utf8) @@ -6414,8 +9980,7 @@ private final class ScriptFixture { /// through the patched build-app.sh above). Tests that need another /// constant (the real CODESIGN) rewrite both copies with it. func writeInstallCopies(extraConstants: [String: String]) throws { - let installText = try String(contentsOf: Self.productionScripts.appendingPathComponent("install.sh"), encoding: .utf8) - let patchedInstall = try Self.patch(installText, [ + let patchedInstall = try Self.patch(script: "install.sh", [ "QUIT_WAIT_SECONDS": "1", "APP_DIR": appsDir.path, "APP_SUPPORT": home.path, @@ -6487,9 +10052,20 @@ private final class ScriptFixture { /// line, so a renamed constant in the script fails loudly here instead of /// letting a test run the real tool. static func patch(_ text: String, _ constants: [String: String]) throws -> String { - var lines = text.components(separatedBy: "\n") + let lines = text.components(separatedBy: "\n") + return try patch(lines, hits: { name in lines.indices.filter { lines[$0].hasPrefix("\(name)=") } }, constants) + } + + /// `patch` on scripts/`name` as it is on disk now (ScriptSource). + static func patch(script name: String, _ constants: [String: String]) throws -> String { + let source = try ScriptSource.shared.lines(name, setting: Array(constants.keys)) + return try patch(source.lines, hits: { source.hits[$0] ?? [] }, constants) + } + + private static func patch(_ lines: [String], hits: (String) -> [Int], _ constants: [String: String]) throws -> String { + var lines = lines for (name, value) in constants { - let hits = lines.indices.filter { lines[$0].hasPrefix("\(name)=") } + let hits = hits(name) guard hits.count == 1 else { throw FixtureError("expected exactly one '\(name)=' line, found \(hits.count)") } @@ -6510,15 +10086,11 @@ private final class ScriptFixture { // MARK: Fakes /// An app bundle's Info.plist, with InsomniaResumeFrozenVersion set to - /// `resumeFrozenVersion` as an integer, or without the key when nil. - static func infoPlist(resumeFrozenVersion: String?) -> String { - let key = resumeFrozenVersion.map { "InsomniaResumeFrozenVersion\($0)" } ?? "" - return """ - - - CFBundleExecutableInsomnia\(key) - - """ + /// `resumeFrozenVersion` as an integer, or without the key when nil, + /// and InsomniaAgentCutoffsVersion set to `agentCutoffsVersion` the + /// same way. + static func infoPlist(resumeFrozenVersion: String?, agentCutoffsVersion: String? = "\(AgentCutoffsCommand.version)") -> String { + BuiltApp.infoPlist(resumeFrozenVersion: resumeFrozenVersion, agentCutoffsVersion: agentCutoffsVersion) } private func writeFake(_ name: String, _ body: String) throws { @@ -6641,10 +10213,82 @@ private final class ScriptFixture { *) exit 1 ;; esac """) + // pmset: `-g batt` is the only form the script may run directly. It + // prints pmset.batt when the test wrote one ("FAIL": exit 1 with no + // output; "HANG": never returns, after writing its pid to + // read.hung.pid and the names of the backstop's files it sees to + // read.files), else a MacBook on AC power at 100%. Any other direct + // call is recorded as DIRECT and fails: power changes go through sudo. try writeFake("pmset", """ + if [[ "${1:-}" == -g && "${2:-}" == batt ]]; then + printf 'pmset -g batt\\n' >> "\(calls)" + if [[ -f "\(r)/pmset.batt" ]]; then + [[ "$(cat "\(r)/pmset.batt")" == FAIL ]] && exit 1 + if [[ "$(cat "\(r)/pmset.batt")" == HANG ]]; then + echo $$ > "\(r)/read.hung.pid" + /bin/ls -A "\(home.path)" | /usr/bin/grep '^\\.backstop\\.' > "\(r)/read.files" || true + exec /bin/sleep 60 + fi + cat "\(r)/pmset.batt"; exit 0 + fi + printf "Now drawing from 'AC Power'\\n -InternalBattery-0 (id=1)\\t100%%; charged; 0:00 remaining present: true\\n" + exit 0 + fi printf 'pmset DIRECT %s\\n' "$*" >> "\(calls)" exit 99 """) + // notifyutil -g : prints " " with the level from + // thermal.mode (default 0). "FAIL": exit 1 with no output. "GARBAGE": + // exit 0 with a line that has no level in it. "HANG": never returns. + // "IGNORE_TERM": never returns and ignores SIGTERM, after writing its + // pid to read.hung.pid; it leaves a child behind that would keep any + // descriptor it inherited. "CHECK_FD9": records whether it has fd 9 + // open, by its own descriptor table and by lsof, then prints level 0. + // lsof can take seconds on a busy machine, so a test using it raises + // the time limit (setCommandTimeout). + try writeFake("notifyutil", """ + printf 'notifyutil %s\\n' "$*" >> "\(calls)" + mode="$(cat "\(r)/thermal.mode" 2>/dev/null || echo 0)" + [[ "$mode" == FAIL ]] && exit 1 + [[ "$mode" == HANG ]] && exec /bin/sleep 60 + if [[ "$mode" == IGNORE_TERM ]]; then + trap '' TERM + echo $$ > "\(r)/read.hung.pid" + /bin/sleep 5 /dev/null 2>&1 & + exec /bin/sleep 60 + fi + if [[ "$mode" == CHECK_FD9 ]]; then + [[ -e /dev/fd/9 ]] && printf 'notifyutil had fd 9\\n' >> "\(calls)" + [[ -n "$(/usr/sbin/lsof -a -p "$$" -d 9 -t 2>/dev/null)" ]] && printf 'notifyutil lsof had fd 9\\n' >> "\(calls)" + printf 'notifyutil checked fd 9\\n' >> "\(calls)" + echo "${2:-} 0"; exit 0 + fi + [[ "$mode" == GARBAGE ]] && { echo "something unexpected"; exit 0; } + echo "${2:-} $mode" + """) + // ioreg -r -c AppleSmartBattery -d 1, by battery_service.mode. + // "NONE" (the default): no service, so nothing is printed. "BATTERY": + // the service, on battery power. "BATTERY_AC": the service with a + // charger. "BATTERY_NOKEY": the service without ExternalConnected. + // "FAIL": exit 1. "HANG": never returns. + try writeFake("ioreg", """ + printf 'ioreg %s\\n' "$*" >> "\(calls)" + mode="$(cat "\(r)/battery_service.mode" 2>/dev/null || echo NONE)" + case "$mode" in + FAIL) exit 1 ;; + HANG) exec /bin/sleep 60 ;; + NONE) exit 0 ;; + esac + echo '+-o AppleSmartBattery ' + echo ' {' + echo ' "AppleRawExternalConnected" = No' + case "$mode" in + BATTERY) echo ' "ExternalConnected" = No' ;; + BATTERY_AC) echo ' "ExternalConnected" = Yes' ;; + esac + echo ' "BatteryInstalled" = Yes' + echo ' }' + """) // swift / codesign: install.sh's build and signing steps, redirected // to a fake binary inside the fixture. try writeFake("swift", """ @@ -6770,6 +10414,9 @@ private final class ScriptFixture { fi exec /bin/chmod "$@" """) + // Insomnia --agent-cutoffs and --agent-session-cutoffs: this build's + // own binary, unrecorded, so config.json and the journal's cutoffs + // are read by the app's decoder as in production. // Insomnia --resume-frozen: reads its entries from standard input, // one " " line each, and records the // call as "Insomnia < ; ; ...". Answers one @@ -6788,6 +10435,7 @@ private final class ScriptFixture { try fm.createDirectory(at: appInfo.deletingLastPathComponent(), withIntermediateDirectories: true) try Self.infoPlist(resumeFrozenVersion: "1").write(to: appInfo, atomically: true, encoding: .utf8) try writeFake("Insomnia", """ + [[ "${1:-}" == --agent-cutoffs || "${1:-}" == --agent-session-cutoffs ]] && exec '\(BuiltApp.binary.path)' "$@" input=() while IFS= read -r line || [[ -n "$line" ]]; do input+=("$line"); done joined="" @@ -7047,6 +10695,43 @@ private final class ScriptFixture { try? value.write(to: root.appendingPathComponent("\(name).mode"), atomically: true, encoding: .utf8) } + /// What the fake `pmset -g batt` prints ("FAIL": it fails instead; + /// "HANG": it never returns). The fixture default is a MacBook on AC + /// power at 100%. + func setBattery(_ output: String) { + try? output.write(to: root.appendingPathComponent("pmset.batt"), atomically: true, encoding: .utf8) + } + + /// `pmset -g batt` as a MacBook prints it, tab and all. + func battery(source: String, percent: Int, state: String = "discharging") -> String { + "Now drawing from '\(source)'\n -InternalBattery-0 (id=22610019)\t\(percent)%; \(state); 0:41 remaining present: true\n" + } + + /// The thermal pressure level the fake notifyutil reports (default 0), + /// or "FAIL" / "GARBAGE" / "HANG". + func setThermal(_ mode: String) { + setMode("thermal", mode) + } + + /// What the fake ioreg reports for AppleSmartBattery: "NONE" (the + /// default, a desktop), "BATTERY", "BATTERY_AC", "BATTERY_NOKEY", + /// "FAIL" or "HANG". + func setBatteryService(_ mode: String) { + setMode("battery_service", mode) + } + + func writeConfig(_ json: String) throws { + try json.write(to: config, atomically: true, encoding: .utf8) + } + + /// Sets COMMAND_TIMEOUT_SECONDS in this fixture's copy of backstop.sh, + /// for a fake command that needs more than the default 1 s to look + /// around before it answers. + func setCommandTimeout(_ seconds: Int) throws { + let text = try String(contentsOf: backstop, encoding: .utf8) + try Self.patch(text, ["COMMAND_TIMEOUT_SECONDS": "\(seconds)"]).write(to: backstop, atomically: true, encoding: .utf8) + } + /// The word the fake app binary answers per pid; the exit status follows /// from the words. func insomniaTable(_ rows: [(pid: Int, word: String)]) throws { @@ -7098,7 +10783,14 @@ private final class ScriptFixture { /// it and keeps running until the test calls releaseCommand (or the /// fixture goes, or a 60 s watchdog), then writes command.ended. Both /// note an inherited fd 9 and record the pid in `sudo.hung.pid`, once - /// the SIGTERM trap is in place. + /// the SIGTERM trap and the watchdog's deadline are in place. The + /// watchdog runs on bash's SECONDS, so the fake forks nothing but + /// /bin/sleep while it waits. A test may signal the fake's process + /// group as soon as the pid appears, and a signal that killed a `date` + /// setting the deadline would leave it at 60, ending the fake at once. + /// A command substitution would also log one signal twice. Bash 3.2 + /// starts it with the shell's pending traps and trap commands, so a + /// SIGTERM that lands just before the fork runs the trap in both. func sudoHangHere() -> String { """ hang_on_term() { @@ -7109,9 +10801,9 @@ private final class ScriptFixture { else trap 'echo "sudo SIGTERM" >> "$calls_log"' TERM fi + deadline=$(( SECONDS + 60 )) echo $$ > "\(root.path)/sudo.hung.pid" - deadline=$(( $(date +%s) + 60 )) - while [[ ! -e "\(root.path)/release" && -d "\(root.path)" && $(date +%s) -lt $deadline ]]; do /bin/sleep 0.1; done + while [[ ! -e "\(root.path)/release" && -d "\(root.path)" && $SECONDS -lt $deadline ]]; do /bin/sleep 0.1; done if [[ -e "\(root.path)/release" ]]; then echo released > "\(root.path)/command.ended" elif [[ -d "\(root.path)" ]]; then echo watchdog > "\(root.path)/command.ended"; fi exit 0 @@ -7130,12 +10822,11 @@ private final class ScriptFixture { """ } - /// A TMPDIR inside the fixture for one run, so a test can check that - /// the script leaves no scratch files behind. + /// The TMPDIR inside the fixture that every run gets (`tmp`), so a + /// test can check that the script leaves no scratch files behind. func privateTmp() throws -> URL { - let dir = root.appendingPathComponent("tmp", isDirectory: true) - try fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + return tmp } /// Whether the hung fake of `tool` ran and has since exited. @@ -7174,10 +10865,6 @@ private final class ScriptFixture { try json.write(to: state, atomically: true, encoding: .utf8) } - func writeConfig(_ json: String) throws { - try json.write(to: config, atomically: true, encoding: .utf8) - } - func writeSession(endsAt: Date) throws { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") @@ -7216,8 +10903,8 @@ private final class ScriptFixture { /// Decoded lossily: the fake launchctl copies the first line of the /// installed binary into the log, which is Mach-O bytes, not text, for /// the fixture the real codesign signs. - func calls() -> [String] { - guard let data = try? Data(contentsOf: callsLog) else { return [] } + func calls(file: StaticString = #filePath, line: UInt = #line) -> [String] { + guard let data = record(callsLog, file: file, line: line) else { return [] } return String(decoding: data, as: UTF8.self).split(separator: "\n").map(String.init) } @@ -7228,17 +10915,51 @@ private final class ScriptFixture { calls().filter { call in !["mktemp", "rm", "rmdir", "mkdir"].contains { call == $0 || call.hasPrefix($0 + " ") } } } - func chmodCalls() -> [String] { - guard let text = try? String(contentsOf: root.appendingPathComponent("chmod.calls"), encoding: .utf8) else { return [] } - return text.split(separator: "\n").map(String.init) + func chmodCalls(file: StaticString = #filePath, line: UInt = #line) -> [String] { + guard let data = record(root.appendingPathComponent("chmod.calls"), file: file, line: line) else { return [] } + return String(decoding: data, as: UTF8.self).split(separator: "\n").map(String.init) + } + + func clearCalls() throws { + try removeIfThere(callsLog) + } + + /// Removes the backstop's log, so a loop's next case cannot pass on a + /// line an earlier case wrote. + func clearLog() throws { + try removeIfThere(logFile) } - func clearCalls() { - try? fm.removeItem(at: callsLog) + /// Removes `url`. Only a file that is not there counts as removed; any + /// other failure throws, so a record that stays cannot pass for one + /// cleared. + private func removeIfThere(_ url: URL) throws { + do { + try fm.removeItem(at: url) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + return + } } - func log() -> String { - (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" + /// The backstop's log, or "" when there is none. A log that is there + /// but cannot be read fails the test instead of reading as empty. + func log(file: StaticString = #filePath, line: UInt = #line) -> String { + String(decoding: record(logFile, file: file, line: line) ?? Data(), as: UTF8.self) + } + + /// The bytes of a file a run or a fake writes, or nil when there is + /// none. A file that is there but cannot be read fails the test instead + /// of reading as missing, so an unread record never passes for no + /// calls or no lines. + private func record(_ url: URL, file: StaticString, line: UInt) -> Data? { + do { + return try Data(contentsOf: url) + } catch CocoaError.fileReadNoSuchFile { + return nil + } catch { + XCTFail("could not read \(url.path): \(error)", file: file, line: line) + return nil + } } /// Environment for the child: no inheritance, so neither the real HOME @@ -7248,6 +10969,7 @@ private final class ScriptFixture { [ "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "INSOMNIA_HOME": home.path, + "TMPDIR": tmp.path, ] } @@ -7268,42 +10990,135 @@ private final class ScriptFixture { /// first, the way uninstall.sh hands its lock handle to the backstop. /// `ignoringTerm` starts the script with SIGTERM ignored, which every /// process it starts inherits (not combined with `fd9`). - /// `extraEnvironment` is for install.sh's refusal test and for a - /// private TMPDIR (see privateTmp). `lastPid` is the script's pid + /// `extraEnvironment` is for install.sh's refusal test and USER; every + /// run already has the fixture's TMPDIR (see privateTmp). `lastPid` is the script's pid /// afterwards: the wrappers exec it, so it is the pid of the process /// started here. private(set) var lastPid: Int32 = 0 func run(_ script: URL, _ args: [String] = [], fd9: URL? = nil, ignoringTerm: Bool = false, extraEnvironment: [String: String] = [:]) throws -> (status: Int32, stdout: String, stderr: String) { + let launch = launch(script, args, fd9: fd9, ignoringTerm: ignoringTerm, extraEnvironment: extraEnvironment) + let (p, childExit) = try launch.start() + lastPid = p.processIdentifier + childExit.wait() + return try launch.result(of: p) + } + + /// One run of a script copy as `run` starts it, held as paths and + /// strings only, so that runs on separate fixtures can go several at a + /// time (`runAll`). + struct Launch: Sendable { + let arguments: [String] + let environment: [String: String] + let directory: URL + let stdout: URL + let stderr: URL + + /// Starts the run. Its output goes to files rather than pipes: + /// nothing to drain, nothing to deadlock. Each file is created + /// empty here, or the run is not started. + func start() throws -> (Process, ProcessExit) { + let fm = FileManager.default + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/bash") + p.arguments = arguments + p.environment = environment + p.currentDirectoryURL = directory + for url in [stdout, stderr] { + guard fm.createFile(atPath: url.path, contents: nil) else { + throw FixtureError("could not create \(url.path) for the run's output") + } + } + let out = try FileHandle(forWritingTo: stdout) + let err = try FileHandle(forWritingTo: stderr) + defer { try? out.close(); try? err.close() } + p.standardOutput = out + p.standardError = err + let childExit = ProcessExit(p) + try p.run() + return (p, childExit) + } + + /// The exit status and what the run printed, once it has exited. An + /// output file that cannot be read throws, so it never reads as a + /// run that printed nothing; bytes that are not UTF-8 are kept as + /// replacement characters. A run a signal ended throws too, with + /// the signal and what it printed on standard error: it has no exit + /// status, and the signal's number must not read as one the script + /// chose. No test ends such a run with a signal (spawn is for that). + func result(of p: Process) throws -> (status: Int32, stdout: String, stderr: String) { + let out = String(decoding: try Data(contentsOf: stdout), as: UTF8.self) + let err = String(decoding: try Data(contentsOf: stderr), as: UTF8.self) + guard p.terminationReason == .exit else { + throw FixtureError("bash \(arguments.joined(separator: " ")) ended on signal \(p.terminationStatus); stderr: \(err)") + } + return (p.terminationStatus, out, err) + } + } + + func launch(_ script: URL, _ args: [String] = [], fd9: URL? = nil, ignoringTerm: Bool = false, extraEnvironment: [String: String] = [:]) -> Launch { precondition(fd9 == nil || !ignoringTerm, "fd9 and ignoringTerm are not combined") - let p = Process() - p.executableURL = URL(fileURLWithPath: "/bin/bash") + let arguments: [String] if let fd9 { - p.arguments = ["-c", #"exec 9<>"$0" && exec /bin/bash "$@""#, fd9.path, script.path] + args + arguments = ["-c", #"exec 9<>"$0" && exec /bin/bash "$@""#, fd9.path, script.path] + args } else if ignoringTerm { - p.arguments = ["-c", #"trap '' TERM && exec /bin/bash "$@""#, "bash", script.path] + args + arguments = ["-c", #"trap '' TERM && exec /bin/bash "$@""#, "bash", script.path] + args } else { - p.arguments = [script.path] + args + arguments = [script.path] + args } - p.environment = childEnvironment.merging(extraEnvironment) { $1 } - p.currentDirectoryURL = root - // Capture to files rather than pipes: nothing to drain, nothing to deadlock. - let outURL = root.appendingPathComponent("stdout.\(UUID().uuidString)") - let errURL = root.appendingPathComponent("stderr.\(UUID().uuidString)") - fm.createFile(atPath: outURL.path, contents: nil) - fm.createFile(atPath: errURL.path, contents: nil) - let out = try FileHandle(forWritingTo: outURL) - let err = try FileHandle(forWritingTo: errURL) - defer { try? out.close(); try? err.close() } - p.standardOutput = out - p.standardError = err - let childExit = ProcessExit(p) - try p.run() - lastPid = p.processIdentifier - childExit.wait() - return (p.terminationStatus, - (try? String(contentsOf: outURL, encoding: .utf8)) ?? "", - (try? String(contentsOf: errURL, encoding: .utf8)) ?? "") + return Launch(arguments: arguments, environment: childEnvironment.merging(extraEnvironment) { $1 }, directory: root, + stdout: root.appendingPathComponent("stdout.\(UUID().uuidString)"), + stderr: root.appendingPathComponent("stderr.\(UUID().uuidString)")) + } + + /// A fixture for a row that runs beside others (runAll). Its copies of + /// backstop.sh and uninstall.sh keep the production limits on commands, + /// the lock and uninstall's calls, not the 1 s and 5 s the other tests + /// set: with eight runs at once, a fake that answers at once here was + /// seen to start too late for 1 s. No fake in such a row hangs, so a + /// limit never fires there. installMachinery() installs this backstop, + /// with those limits. + static func concurrentRow() throws -> ScriptFixture { + try ScriptFixture(productionLimits: true) + } + + /// Runs each launch once, at most `width` at a time, and returns their + /// results in order. The runs share no file they write: each launch + /// belongs to its own fixture or works only in folders of its own, as + /// the journal acceptance table's do. Every run that started is waited + /// for and reaped, also when another fails to start: the group waits for + /// its tasks before it throws. + static func runAll(_ launches: [Launch], width: Int = 16) async throws -> [(status: Int32, stdout: String, stderr: String)] { + var results = [(status: Int32, stdout: String, stderr: String)](repeating: (-1, "", ""), count: launches.count) + try await withThrowingTaskGroup(of: (Int, Int32, String, String).self) { group in + var next = 0 + func add() { + guard next < launches.count else { return } + let (i, launch) = (next, launches[next]) + group.addTask { + let (p, childExit) = try launch.start() + await childExit.exited() + let r = try launch.result(of: p) + return (i, r.status, r.stdout, r.stderr) + } + next += 1 + } + for _ in 0.. AppAliveLock { + let lock = AppAliveLock(url: alive) + guard try lock.tryAcquire() else { throw FixtureError("could not take \(alive.lastPathComponent): another holder has it") } + return lock } /// The pid a hung fake of `tool` recorded (see hangHere), once it has @@ -7340,9 +11155,9 @@ private final class ScriptFixture { } /// How many times the scripts called the slow `sleep`. - func slowPolls() -> Int { - let text = (try? String(contentsOf: root.appendingPathComponent("slow-poll.log"), encoding: .utf8)) ?? "" - return text.split(separator: "\n").count + func slowPolls(file: StaticString = #filePath, line: UInt = #line) -> Int { + String(decoding: record(root.appendingPathComponent("slow-poll.log"), file: file, line: line) ?? Data(), as: UTF8.self) + .split(separator: "\n").count } /// Runs a real tool (not a script) with the fixture's environment: the @@ -7364,153 +11179,88 @@ private final class ScriptFixture { return (p.terminationStatus, String(decoding: data, as: UTF8.self)) } - /// A script started in the background by `spawn`. Nothing in the test - /// process reaps it but `wait`: Foundation reaps only the Processes it - /// started, and nothing else here calls waitpid. Until `wait`, its pid - /// stays its own even after it exits (as a zombie), so `signal` reaches - /// this child or nothing, never a process that reused the pid. - final class Spawned { - let pid: pid_t - /// Started as the leader of a process group of its own (see spawn). - let leadsGroup: Bool - private(set) var status: Int32? - - init(pid: pid_t, leadsGroup: Bool) { - self.pid = pid - self.leadsGroup = leadsGroup - } - - /// Whether the child has exited, checked without reaping it. - var hasExited: Bool { - guard status == nil else { return true } - var info = siginfo_t() - return waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid == pid - } - - /// Sends `sig` to the child. Refuses with -1 once `wait` has reaped - /// it, since the pid may then belong to another process. - func signal(_ sig: Int32) -> Int32 { - guard status == nil else { return -1 } - return kill(pid, sig) - } - - /// Sends `sig` to every process in the group the child leads, the - /// way launchd signals what is left of a job's process group once - /// its main process has exited. Only for a child spawned with - /// `ownProcessGroup`, so the group holds nothing but the child and - /// what it started. Refuses with -1 once `wait` has reaped the - /// child: until then its pid, the group's id, cannot name another - /// process or group. - func signalGroup(_ sig: Int32) -> Int32 { - guard leadsGroup, status == nil else { return -1 } - return killpg(pid, sig) - } - - /// Waits for the child to exit, reaps it, and returns its wait - /// status (-1 if waitpid failed). - @discardableResult - func wait() -> Int32 { - if let status { return status } - var raw: Int32 = 0 - var reaped: pid_t - repeat { reaped = waitpid(pid, &raw, 0) } while reaped == -1 && errno == EINTR - let result = reaped == pid ? raw : -1 - status = result - return result - } - } + /// A script started in the background by `spawn` (OwnedChild). + typealias Spawned = OwnedChild /// Starts `script` under /bin/bash with the same environment (plus - /// `extraEnvironment`) and working directory as `run`, standard input and output on /dev/null, - /// and returns without waiting for it. Like a Process, the child gets - /// no other descriptor of the test process, an empty signal mask and - /// default signal actions. With `ownProcessGroup` it leads a new process - /// group, as launchd starts a job, instead of joining the test's. + /// `extraEnvironment`) and working directory as `run`, standard input and + /// output on /dev/null, and returns without waiting for it. The child is + /// set up as spawnOwnedChild describes: each setup call is checked, and + /// one that fails throws before anything starts. func spawn(_ script: URL, extraEnvironment: [String: String] = [:], ownProcessGroup: Bool = false) throws -> Spawned { - var actions: posix_spawn_file_actions_t? - posix_spawn_file_actions_init(&actions) - defer { posix_spawn_file_actions_destroy(&actions) } - posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) - posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0) - posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) - posix_spawn_file_actions_addchdir(&actions, root.path) - - var attr: posix_spawnattr_t? - posix_spawnattr_init(&attr) - defer { posix_spawnattr_destroy(&attr) } - var mask = sigset_t() - sigemptyset(&mask) - var defaults = sigset_t() - sigfillset(&defaults) - sigdelset(&defaults, SIGKILL) - sigdelset(&defaults, SIGSTOP) - posix_spawnattr_setsigmask(&attr, &mask) - posix_spawnattr_setsigdefault(&attr, &defaults) - var flags = POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF - if ownProcessGroup { - posix_spawnattr_setpgroup(&attr, 0) - flags |= POSIX_SPAWN_SETPGROUP - } - posix_spawnattr_setflags(&attr, Int16(flags)) - - let arguments = ["/bin/bash", script.path] - let argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } + [nil] - let environment = childEnvironment.merging(extraEnvironment) { $1 }.map { "\($0.key)=\($0.value)" } - let envp: [UnsafeMutablePointer?] = environment.map { strdup($0) } + [nil] - defer { (argv + envp).forEach { free($0) } } - - var pid: pid_t = 0 - let spawned = posix_spawn(&pid, "/bin/bash", &actions, &attr, argv, envp) - guard spawned == 0, pid > 0 else { throw FixtureError("posix_spawn /bin/bash \(script.path) failed: \(spawned)") } - return Spawned(pid: pid, leadsGroup: ownProcessGroup) - } - - /// A lockf process that holds the recovery lock. - struct LockHolder { - let process: Process - let exit: ProcessExit - - /// Terminates the holder and returns once it has exited. - func stop() { - process.terminate() - exit.wait() - } + try spawnOwnedChild( + "/bin/bash", ["/bin/bash", script.path], environment: childEnvironment.merging(extraEnvironment) { $1 }, + directory: root, ownProcessGroup: ownProcessGroup) } + /// Every lock holder `holdLock` started, so `destroy` can stop one a + /// test left running. + private var holders: [LockfHolder] = [] + /// Probes `holdLock` could not reap: each still runs, so `destroy` + /// keeps the tree they use. + private var unsettledProbes: [String] = [] + /// Holds the recovery lock from another process, the way a running app - /// or a concurrent backstop would, until stopped. - func holdLock() throws -> LockHolder { - let p = Process() - p.executableURL = URL(fileURLWithPath: "/usr/bin/lockf") - // The holder waits (not -t 0): a probe below may briefly own the lock - // at the same instant, and the holder must outlast that, not give up. - p.arguments = ["-k", "-t", "10", lock.path, "/bin/sleep", "30"] - let diagURL = root.appendingPathComponent("lock-holder.log") - fm.createFile(atPath: diagURL.path, contents: nil) - let diag = try FileHandle(forWritingTo: diagURL) - defer { try? diag.close() } - p.standardOutput = diag - p.standardError = diag - let holder = LockHolder(process: p, exit: ProcessExit(p)) - try p.run() - // Wait until the holder really owns the lock. + /// or a concurrent backstop would, until stopped (LockfHolder). The + /// holder waits up to 10 s for the lock (not -t 0): a probe below may + /// briefly own the lock at the same instant, and the holder must outlast + /// that, not give up. `attempts` probes, 50 ms apart, look for the + /// holder owning the lock, and cat printing a line back confirms it. + /// Otherwise the holder is stopped and reaped and its exit status is + /// part of the error; a holder or probe that could not be reaped is + /// named there as unsettled instead. + func holdLock(attempts: Int = 50) throws -> LockfHolder { + let holder = try LockfHolder( + file: lock, wait: 10, errors: root.appendingPathComponent("lock-holder.log"), + environment: childEnvironment, directory: root) + holders.append(holder) + func failure(_ why: String, probes: [Int32]) -> FixtureError { + let status = holder.stop() + let ended = holder.isSettled ? "holder exited \(status) and was reaped" : "holder did not exit and is unsettled" + return FixtureError("\(why); \(ended), probes=\(probes) problems=\(holder.problems) output=\(holder.errorText())") + } var probes: [Int32] = [] - for _ in 0..<50 { - let probe = Process() - probe.executableURL = URL(fileURLWithPath: "/usr/bin/lockf") - probe.arguments = ["-k", "-s", "-t", "0", lock.path, "/usr/bin/true"] - probe.standardOutput = diag - probe.standardError = diag - let probeExit = ProcessExit(probe) - try probe.run() - probeExit.wait() - probes.append(probe.terminationStatus) - if probe.terminationStatus == 75 { return holder } + let probeLog = root.appendingPathComponent("lock-probes.log") + let probeOutput = open(probeLog.path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0o600) + guard probeOutput >= 0 else { + throw failure("could not open \(probeLog.path) for the probes' output: errno \(errno)", probes: probes) + } + defer { close(probeOutput) } + for _ in 0..> 8) & 0xff + probes.append(status) + if status == 75 { + guard holder.held(within: 5) else { + throw failure("a probe found the lock taken, but cat did not print its line back", probes: probes) + } + return holder + } Thread.sleep(forTimeInterval: 0.05) } - p.terminate() - let text = (try? String(contentsOf: diagURL, encoding: .utf8)) ?? "" - throw FixtureError("could not take the recovery lock for the contention test; holder running=\(p.isRunning) probes=\(probes) output=\(text)") + throw failure("could not take the recovery lock for the contention test", probes: probes) } } @@ -7906,81 +11656,99 @@ final class AppEncodedJournalScriptTests: XCTestCase { /// The agent on an expired session: the same outcome for each name or /// UID as for a plain one, with or without a kept display record, in /// the current entry form and the legacy one. The app then restores - /// the device from the journal the agent published. + /// the device from the journal the agent published. The journals are + /// written one at a time (each Harness points INSOMNIA_HOME at its + /// home), the agent runs go several at a time, one fixture each, and + /// the app's restores one at a time again. func testAnEscapedNameOrUIDDoesNotStopTheAgentAndTheAppRestoresIt() async throws { + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + var rows: [(label: String, uid: String, name: String, f: ScriptFixture, before: Data)] = [] for v in Self.variants() { for record in [false, true] { for legacy in [false, true] { let label = "\(v.label) record \(record) legacy \(legacy)" - let f = try ScriptFixture() - defer { f.destroy() } + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) let before = try await appJournal(uid: v.uid, name: v.name, record: record, legacy: legacy, boot: f.bootUUID) if v.label != "plain" { XCTAssertTrue(String(decoding: before, as: UTF8.self).contains("\(backslash)\(backslash)u0041"), label) } try before.write(to: f.state) try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) - - let r = try f.run(f.backstop) - - XCTAssertEqual(r.status, 0, "\(label): \(r.stderr) \(f.log())") - XCTAssertFalse(f.exists(f.session), label) - try checkUndone(f, before: before, label) - - let h = Harness() - defer { h.home.destroy() } - h.audio.connect(v.uid, name: v.name, volume: 0.6, muted: true) - h.clamshell.closed = false - try Data(contentsOf: f.state).write(to: h.home.paths.stateFile) - let m = h.makeManager(bootSession: f.bootUUID) - await m.reconcile() - - XCTAssertEqual(h.audio.device(v.uid)?.muted, false, label) - XCTAssertEqual(h.audio.device(v.uid)?.volume, 0.6, label) - XCTAssertEqual(try h.store.loadState()?.savedAudioOutputs, [], label) + rows.append((label, v.uid, v.name, f, before)) } } } + + let results = try await ScriptFixture.runAll(rows.map { $0.f.launch($0.f.backstop) }) + + for (row, r) in zip(rows, results) { + let (label, f) = (row.label, row.f) + XCTAssertEqual(r.status, 0, "\(label): \(r.stderr) \(f.log())") + XCTAssertFalse(f.exists(f.session), label) + try checkUndone(f, before: row.before, label) + + let h = Harness() + defer { h.home.destroy() } + h.audio.connect(row.uid, name: row.name, volume: 0.6, muted: true) + h.clamshell.closed = false + try Data(contentsOf: f.state).write(to: h.home.paths.stateFile) + let m = h.makeManager(bootSession: f.bootUUID) + await m.reconcile() + + XCTAssertEqual(h.audio.device(row.uid)?.muted, false, label) + XCTAssertEqual(h.audio.device(row.uid)?.volume, 0.6, label) + XCTAssertEqual(try h.store.loadState()?.savedAudioOutputs, [], label) + } } /// uninstall.sh, with and without --purge, runs the backstop it /// installed over the same journals: the undo goes as far as for a /// plain name, and the saved output, which only the app restores, /// stops the removal with the app, the agent and the sudoers rule in - /// place. + /// place. The journals are written one at a time, and the uninstalls + /// go several at a time, one fixture each. func testAnEscapedNameOrUIDDoesNotStopTheUndoOfAnUninstall() async throws { + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } + var rows: [(label: String, f: ScriptFixture, before: Data, purge: Bool)] = [] for v in Self.variants() { for record in [false, true] { for legacy in [false, true] { for purge in [false, true] { let label = "\(v.label) record \(record) legacy \(legacy) purge \(purge)" - let f = try ScriptFixture() - defer { f.destroy() } + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) let before = try await appJournal(uid: v.uid, name: v.name, record: record, legacy: legacy, boot: f.bootUUID) try f.installMachinery() try before.write(to: f.state) try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) - - let r = try f.run(f.uninstall, purge ? ["--purge"] : []) - - XCTAssertNotEqual(r.status, 0, label) - XCTAssertTrue(f.exists(f.app), label) - XCTAssertTrue(f.exists(f.plist), label) - XCTAssertTrue(f.exists(f.sudoers), label) - XCTAssertFalse(r.stderr.contains("malformed"), "\(label): \(r.stderr)") - XCTAssertTrue(r.stderr.contains("audio") || r.stdout.contains("audio"), "\(label): \(r.stdout) \(r.stderr)") - try checkUndone(f, before: before, label) + rows.append((label, f, before, purge)) } } } } + + let results = try await ScriptFixture.runAll(rows.map { $0.f.launch($0.f.uninstall, $0.purge ? ["--purge"] : []) }) + + for (row, r) in zip(rows, results) { + let (label, f) = (row.label, row.f) + XCTAssertNotEqual(r.status, 0, label) + XCTAssertTrue(f.exists(f.app), label) + XCTAssertTrue(f.exists(f.plist), label) + XCTAssertTrue(f.exists(f.sudoers), label) + XCTAssertFalse(r.stderr.contains("malformed"), "\(label): \(r.stderr)") + XCTAssertTrue(r.stderr.contains("audio") || r.stdout.contains("audio"), "\(label): \(r.stdout) \(r.stderr)") + try checkUndone(f, before: row.before, label) + } } /// Journals the app reads, each with sleep journaled, through the /// agent: what is around the records, in strings and nested values, /// holds none, and the forms of a record the app reads pass. Sleep is - /// undone each time. - func testWhatTheAppReadsAroundTheRecordsDoesNotStopTheAgent() throws { + /// undone each time. The runs go several at a time, one fixture each. + func testWhatTheAppReadsAroundTheRecordsDoesNotStopTheAgent() async throws { let b = backslash let base = #"{"sleepDisabledByUs":true,"lowPowerSetByUs":false,"frozenProcesses":[],"dockerFrozen":false"# let tails: [(label: String, tail: String)] = [ @@ -7995,16 +11763,20 @@ final class AppEncodedJournalScriptTests: XCTestCase { ("an integer", #","keptDisplayReadLit":1}"#), ("null", #","keptDisplayReadLit":null}"#), ] + var fixtures: [ScriptFixture] = [] + defer { fixtures.forEach { $0.destroy() } } for (label, tail) in tails { let json = base + tail XCTAssertNoThrow(try Store.makeDecoder().decode(RuntimeState.self, from: Data(json.utf8)), label) - let f = try ScriptFixture() - defer { f.destroy() } + let f = try ScriptFixture.concurrentRow() + fixtures.append(f) try f.writeState(json) try f.writeSession(endsAt: Date(timeIntervalSinceNow: -60)) + } - let r = try f.run(f.backstop) + let results = try await ScriptFixture.runAll(fixtures.map { $0.launch($0.backstop) }) + for ((label, _), (f, r)) in zip(tails, zip(fixtures, results)) { XCTAssertEqual(r.status, 0, "\(label): \(r.stderr) \(f.log())") XCTAssertTrue(f.calls().contains("sudo -n \(f.fakePmset) -a disablesleep 0"), label) XCTAssertEqual(try f.stateJSON()["sleepDisabledByUs"] as? Bool, false, label) diff --git a/Tests/InsomniaTests/ReleaseWorkflowTests.swift b/Tests/InsomniaTests/ReleaseWorkflowTests.swift index 4bde50cc..fc3a3d11 100644 --- a/Tests/InsomniaTests/ReleaseWorkflowTests.swift +++ b/Tests/InsomniaTests/ReleaseWorkflowTests.swift @@ -307,7 +307,7 @@ final class ReleaseWorkflowTests: XCTestCase { /// end. Returns what the step wrote to $GITHUB_OUTPUT and the calls. private func runStep(_ script: String, gh body: String, files: [String: String], environment: [String: String]) throws -> StepResult { let fm = FileManager.default - let root = fm.temporaryDirectory.appendingPathComponent("release-step-\(UUID().uuidString)", isDirectory: true) + let root = ProcessTestHome.temporaryDirectory.appendingPathComponent("release-step-\(UUID().uuidString)", isDirectory: true) let bin = root.appendingPathComponent("bin", isDirectory: true) try fm.createDirectory(at: bin, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(at: root) } @@ -338,7 +338,7 @@ final class ReleaseWorkflowTests: XCTestCase { p.arguments = ["-e", scriptURL.path] p.currentDirectoryURL = root p.environment = environment.merging([ - "PATH": "\(bin.path):/usr/bin:/bin", "TMPDIR": NSTemporaryDirectory(), + "PATH": "\(bin.path):/usr/bin:/bin", "TMPDIR": ProcessTestHome.temporaryDirectory.path + "/", "GITHUB_REPOSITORY": "krishhgg/Insomnia", "GITHUB_OUTPUT": outputURL.path, ]) { _, fixed in fixed } // Capture to files rather than pipes: nothing to drain, nothing to deadlock. @@ -443,17 +443,63 @@ final class ReleaseWorkflowTests: XCTestCase { /// theirs is wherever the user unpacked it (/tmp, Downloads). They take /// sibling scripts from their own folder only; a path built from the /// parent of the script's folder would run whatever another account put - /// there. RecoveryScriptTests runs both from a zip layout. + /// there. Each script's own code up to in_checkout runs here from a zip + /// folder whose parent looks like a source checkout (scripts/ and + /// Package.swift): SCRIPT_DIR is the zip folder, which is no checkout. + /// uninstall.sh also runs with a dirname first in PATH and CDPATH that + /// both name a decoy folder, and finds its own folder without either; + /// install.sh's line is main's and is run without them. + /// RecoveryScriptTests runs both whole from a zip layout. func testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwn() throws { let text = try releaseWorkflow() let copy = try XCTUnwrap(lines(text).first { $0.contains(#""release/$pkg/""#) && $0.contains("cp ") }, "no cp into the package folder") let shipped = copy.split(separator: " ").map(String.init).filter { $0.hasPrefix("scripts/") } XCTAssertEqual(shipped, ["scripts/install.sh", "scripts/uninstall.sh"]) let repo = workflowsDir.deletingLastPathComponent().deletingLastPathComponent() + let fm = FileManager.default + let tmp = ProcessTestHome.temporaryDirectory.appendingPathComponent("zip-scripts-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: tmp) } + let unpackedIn = tmp.appendingPathComponent("unpacked", isDirectory: true) + let zip = unpackedIn.appendingPathComponent("Insomnia-1.0", isDirectory: true) + let decoy = tmp.appendingPathComponent("decoy", isDirectory: true) + for dir in [zip, unpackedIn.appendingPathComponent("scripts"), decoy.appendingPathComponent("Insomnia-1.0")] { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + try "".write(to: unpackedIn.appendingPathComponent("Package.swift"), atomically: true, encoding: .utf8) + try "".write(to: unpackedIn.appendingPathComponent("scripts/backstop.sh"), atomically: true, encoding: .utf8) + let shadow = tmp.appendingPathComponent("bin", isDirectory: true) + try fm.createDirectory(at: shadow, withIntermediateDirectories: true) + let ranShadow = tmp.appendingPathComponent("dirname.ran") + try "#!/bin/bash\ntouch '\(ranShadow.path)'\necho '\(decoy.path)'\n".write(to: shadow.appendingPathComponent("dirname"), atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shadow.appendingPathComponent("dirname").path) for path in shipped { + let name = String(path.dropFirst("scripts/".count)) let script = try String(contentsOf: repo.appendingPathComponent(path), encoding: .utf8) XCTAssertFalse(script.contains(#"BASH_SOURCE[0]}")/.."#), "\(path) resolves a path from the parent of its folder") - XCTAssertTrue(script.contains(#"SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)""#), "\(path) has no SCRIPT_DIR") + let checkout = try XCTUnwrap(script.range(of: "\nin_checkout() {"), "\(path) has no in_checkout") + let lineEnd = try XCTUnwrap(script.range(of: "\n", range: checkout.upperBound.. Bool { + realLocations.contains { isInside(url, $0) } } /// `url` is `dir` or sits inside it. `standardized` only removes `.` and @@ -40,10 +49,10 @@ final class TestIsolationTests: XCTestCase { ] } - /// True only when nothing the app would write resolves into the real - /// ~/Library. Callers that would write must stop when this is false. + /// True only when nothing the app would write resolves into one of its + /// real locations. Callers that would write must stop when this is false. private func isIsolated(_ paths: Paths) -> Bool { - paths != Paths.standard && !locations(paths).contains { isInside($0, realLibrary) } + paths != Paths.standard && !locations(paths).contains { isInsideReal($0) } } private func isDirectory(_ url: URL) -> Bool { @@ -56,7 +65,39 @@ final class TestIsolationTests: XCTestCase { let root = ProcessTestHome.root XCTAssertTrue(root.lastPathComponent.hasPrefix("insomnia-tests-process-"), root.path) XCTAssertTrue(isDirectory(root), "the loader's directory does not exist: \(root.path)") - XCTAssertFalse(isInside(root, realLibrary), root.path) + XCTAssertFalse(isInsideReal(root), root.path) + } + + /// TMPDIR as the run set it. Unset or empty sends the loader to Darwin's + /// per-user temp directory, then /tmp, and there is nothing to compare. + private func runTMPDIR() throws -> URL { + guard let raw = getenv("TMPDIR"), raw.pointee != 0 else { throw XCTSkip("TMPDIR is unset") } + return URL(fileURLWithPath: String(cString: raw), isDirectory: true) + } + + /// `a` and `b` are the same directory: the same device and inode. + private func assertSameDirectory(_ a: URL, _ b: URL, file: StaticString = #filePath, line: UInt = #line) { + var sa = stat(), sb = stat() + XCTAssertEqual(stat(a.path, &sa), 0, "stat \(a.path): errno \(errno)", file: file, line: line) + XCTAssertEqual(stat(b.path, &sb), 0, "stat \(b.path): errno \(errno)", file: file, line: line) + XCTAssertEqual(sa.st_dev, sb.st_dev, "\(a.path) and \(b.path)", file: file, line: line) + XCTAssertEqual(sa.st_ino, sb.st_ino, "\(a.path) and \(b.path)", file: file, line: line) + } + + func testLoaderHomeSitsInTMPDIRWhenTheRunSetsIt() throws { + let tmp = try runTMPDIR() + let parent = ProcessTestHome.root.deletingLastPathComponent() + // The loader joins TMPDIR and the name, so the path matches too. + XCTAssertEqual(parent.standardized.path, tmp.standardized.path) + assertSameDirectory(parent, tmp) + } + + func testScratchDirectoryIsTMPDIRWhenTheRunSetsIt() throws { + let tmp = try runTMPDIR() + assertSameDirectory(ProcessTestHome.temporaryDirectory, tmp) + let home = TempHome() + defer { home.destroy() } + assertSameDirectory(home.root.deletingLastPathComponent(), tmp) } func testEveryResolvedPathSitsInsideTheTestHome() throws { @@ -64,13 +105,13 @@ final class TestIsolationTests: XCTestCase { XCTAssertFalse(value.isEmpty, "INSOMNIA_HOME is empty") let home = URL(fileURLWithPath: value, isDirectory: true) XCTAssertTrue(isDirectory(home), "INSOMNIA_HOME does not exist: \(home.path)") - XCTAssertFalse(isInside(home, realLibrary), home.path) + XCTAssertFalse(isInsideReal(home), home.path) let resolved = Paths.fromEnvironment() XCTAssertNotEqual(resolved, Paths.standard) for url in locations(resolved) { XCTAssertTrue(isInside(url, home), "\(url.path) is outside INSOMNIA_HOME \(home.path)") - XCTAssertFalse(isInside(url, realLibrary), url.path) + XCTAssertFalse(isInsideReal(url), url.path) } } @@ -100,7 +141,7 @@ final class TestIsolationTests: XCTestCase { func testTempHomeDestroyKeepsTheProcessWideHome() { let home = TempHome() XCTAssertEqual(ProcessTestHome.current, home.root.path) - XCTAssertFalse(isInside(home.root, realLibrary), home.root.path) + XCTAssertFalse(isInsideReal(home.root), home.root.path) home.destroy() diff --git a/Tests/InsomniaTests/TestSupport.swift b/Tests/InsomniaTests/TestSupport.swift index baee876e..eb14381c 100644 --- a/Tests/InsomniaTests/TestSupport.swift +++ b/Tests/InsomniaTests/TestSupport.swift @@ -47,6 +47,19 @@ enum ProcessTestHome { return URL(fileURLWithPath: String(cString: raw), isDirectory: true) }() + /// The directory the loader made `root` in: TMPDIR when the run set it, + /// else Darwin's per-user temp directory, then /tmp. Tests make their + /// scratch files here. FileManager's temporaryDirectory and + /// NSTemporaryDirectory are Darwin's per-user directory whatever TMPDIR + /// says, so a run that points TMPDIR at its own folder would not find + /// their files there. + static let temporaryDirectory: URL = { + guard let raw = insomnia_test_home_temp_dir() else { + fatalError("InsomniaTestHome did not run at load; refusing to test against the real ~/Library") + } + return URL(fileURLWithPath: String(cString: raw), isDirectory: true) + }() + /// Where INSOMNIA_HOME points right now, as the app would resolve it. static var current: String? { guard let value = getenv(Paths.environmentKey) else { return nil } @@ -62,7 +75,7 @@ final class TempHome { let paths: Paths init() { - root = FileManager.default.temporaryDirectory + root = ProcessTestHome.temporaryDirectory .appendingPathComponent("insomnia-tests-\(UUID().uuidString)", isDirectory: true) try! FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) setenv(Paths.environmentKey, root.path, 1) @@ -73,6 +86,24 @@ final class TempHome { setenv(Paths.environmentKey, ProcessTestHome.root.path, 1) try? FileManager.default.removeItem(at: root) } + + /// Hands the variable back as `destroy` does but leaves `root` in place, + /// for a test whose child process may still use it: removing a folder + /// under a live child would hide what it does next. + func keep() { + setenv(Paths.environmentKey, ProcessTestHome.root.path, 1) + } +} + +/// Points INSOMNIA_HOME at `home` for a test that does not own a TempHome, +/// and returns the closure that puts back the value it had before (the +/// loader's `ProcessTestHome.root` if it somehow had none). Call it in a +/// defer. Never unsetenv instead: `Log.append` and `SessionManager.live` +/// would then resolve the real ~/Library for the rest of the process. +func pointInsomniaHome(at home: URL) -> () -> Void { + let previous = ProcessTestHome.current ?? ProcessTestHome.root.path + setenv(Paths.environmentKey, home.path, 1) + return { setenv(Paths.environmentKey, previous, 1) } } /// Records every call; can be told to throw. @@ -439,6 +470,9 @@ final class FakeAudioControl: AudioControlling, @unchecked Sendable { lock.withLock { _devices[uid]?.volume = volume; _devices[uid]?.muted = muted } } + /// Whether SessionManager has registered for device changes. + var watched: Bool { lock.withLock { _devicesChanged != nil } } + /// Calls the handler SessionManager registered, as CoreAudio does when /// a device connects or disconnects. func fireDevicesChanged() { @@ -854,6 +888,30 @@ struct Harness { } } +/// Sets or clears the user immutable flag (chflags uchg) on a test file, so +/// unlink and rename onto it fail with EPERM, as for a file a person locked. +/// Tests clear it again before their temp home is removed. +func setImmutable(_ url: URL, _ on: Bool) throws { + try FileManager.default.setAttributes([.immutable: on], ofItemAtPath: url.path) +} + +/// Runs `body` with this process's file size limit at `bytes` and SIGXFSZ +/// ignored, so a write past that offset stops there, as one cut short by a +/// full disk does: the kernel writes the bytes below the limit and fails +/// the rest with EFBIG. The limit holds for the whole process, so it is set +/// around one write only. +func withFileSizeLimit(_ bytes: Int, _ body: () -> T) throws -> T { + var old = rlimit() + guard getrlimit(RLIMIT_FSIZE, &old) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINVAL) } + var limited = old + limited.rlim_cur = rlim_t(bytes) + let handler = signal(SIGXFSZ, SIG_IGN) + defer { signal(SIGXFSZ, handler) } + guard setrlimit(RLIMIT_FSIZE, &limited) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINVAL) } + defer { setrlimit(RLIMIT_FSIZE, &old) } + return body() +} + /// Runs `request` in a new main-actor task and returns that task once the /// request has been called and the task has let go of the main actor: at /// its first suspension, or because it finished. Lifecycle requests join @@ -967,12 +1025,17 @@ final class BlockingKeychain: KeychainStoring, @unchecked Sendable { /// something does, open(2) blocks until a writer appears; the watchdog opens /// the FIFO for writing once a second, which lets a blocked reader through /// (it reads EOF) and records that a reader was there. A regression then -/// fails its test instead of hanging the suite. +/// fails its test instead of hanging the suite. `stop` returns only once +/// the watchdog's thread has returned, so nothing opens the path after it. final class FIFOWatch: @unchecked Sendable { let url: URL private let lock = NSLock() - private var stopped = false private var seen = false + /// Signalled by `stop`; ends the watchdog's wait between checks early. + private let stopSignal = DispatchSemaphore(value: 0) + /// Signalled once when the watchdog's thread returns. Each `stop` takes + /// it and puts it back, so every call returns. + private let finished = DispatchSemaphore(value: 0) init(at url: URL) throws { self.url = url @@ -981,9 +1044,10 @@ final class FIFOWatch: @unchecked Sendable { } let path = url.path Thread.detachNewThread { [self] in + defer { self.finished.signal() } for _ in 0..<120 { - Thread.sleep(forTimeInterval: 1) - if self.isStopped { return } + // A second between checks, as before, unless stopped first. + if self.stopSignal.wait(timeout: .now() + 1) == .success { return } // Succeeds only while a reader has the FIFO open. let fd = open(path, O_WRONLY | O_NONBLOCK) if fd >= 0 { @@ -1003,27 +1067,79 @@ final class FIFOWatch: @unchecked Sendable { return lstat(url.path, &info) == 0 && info.st_mode & S_IFMT == S_IFIFO } - func stop() { lock.lock(); stopped = true; lock.unlock() } + /// Stops the watchdog and returns once its thread has returned: at + /// most the open and close of a check under way, never a second's wait. + func stop() { + stopSignal.signal() + finished.wait() + finished.signal() + } - private var isStopped: Bool { lock.lock(); defer { lock.unlock() }; return stopped } private func markSeen() { lock.lock(); seen = true; lock.unlock() } } +/// The ACL entries one test gives (TestACL.denyNewFiles), so that its +/// cleanup takes off exactly those. No ACL is read to find them, and a test +/// that gives none makes no ACL call at all, in its body or its tearDown. +final class OwnedACLs { + private var given: [URL] = [] + + /// TestACL.denyNewFiles, kept for removeGiven once chmod added it. + func denyNewFiles(in dir: URL) throws { + try TestACL.denyNewFiles(in: dir) + given.append(dir) + } + + /// TestACL.removeAll on `dir`, which this test gave an entry. + func removeAll(_ dir: URL) throws { + try TestACL.removeAll(dir) + given.removeAll { $0 == dir } + } + + /// Takes off the entries still given, for a tearDown; runs nothing + /// when there are none. + func removeGiven() { + for dir in given { try? TestACL.removeAll(dir) } + given = [] + } +} + /// Access control lists for the tests that check Insomnia leaves them alone. enum TestACL { /// Gives `url` one entry letting its owner, the user running the tests, /// read it. On a 0200 file that entry is the only way to read it. static func grantOwnerRead(_ url: URL) throws { + try chmod(["+a", "user:\(owner) allow read", url.path]) + } + + /// Gives directory `dir` one entry that stops its owner creating files + /// in it, so the temp file of an atomic write fails as on a full disk. + /// A directory inside can still be renamed: that needs + /// add_subdirectory, which the entry leaves allowed. + static func denyNewFiles(in dir: URL) throws { + try chmod(["+a", "user:\(owner) deny add_file", dir.path]) + } + + /// Removes every ACL entry from `url`, which the caller gave one + /// (OwnedACLs keeps which): no ACL is read first. + static func removeAll(_ url: URL) throws { + try chmod(["-N", url.path]) + } + + private static var owner: String { String(cString: getpwuid(getuid()).pointee.pw_name) } + + private static func chmod(_ arguments: [String]) throws { let chmod = Process() chmod.executableURL = URL(fileURLWithPath: "/bin/chmod") - chmod.arguments = ["+a", "user:\(String(cString: getpwuid(getuid()).pointee.pw_name)) allow read", url.path] + chmod.arguments = arguments let exit = ProcessExit(chmod) try chmod.run() exit.wait() guard chmod.terminationStatus == 0 else { throw POSIXError(.EPERM) } } - /// How many ACL entries `url` has, without following a symlink. + /// How many ACL entries `url` has, without following a symlink. Only + /// for a test that checks the entries it gave. static func entries(_ url: URL) -> Int { guard let acl = acl_get_link_np(url.path, ACL_TYPE_EXTENDED) else { return 0 } defer { acl_free(UnsafeMutableRawPointer(acl)) } @@ -1037,3 +1153,82 @@ enum TestACL { return count } } + +/// The Insomnia executable this build made, beside the test bundle. Tests +/// that run backstop.sh hand it config.json through `--agent-cutoffs`, as +/// the installed app binary is in production, so the agent's reading of the +/// file is the app's decoder and not a test double. +enum BuiltApp { + private final class Marker {} + + static var binary: URL { + Bundle(for: Marker.self).bundleURL.deletingLastPathComponent().appendingPathComponent("Insomnia") + } + + /// An app bundle's Info.plist declaring `InsomniaAgentCutoffsVersion` + /// as `agentCutoffsVersion`, or without that key when nil, and + /// `InsomniaResumeFrozenVersion` likewise. + static func infoPlist(resumeFrozenVersion: String? = nil, agentCutoffsVersion: String?) -> String { + let frozen = resumeFrozenVersion.map { "InsomniaResumeFrozenVersion\($0)" } ?? "" + let cutoffs = agentCutoffsVersion.map { "InsomniaAgentCutoffsVersion\($0)" } ?? "" + return """ + + + CFBundleExecutableInsomnia\(frozen)\(cutoffs) + + """ + } +} + +/// The scripts in this checkout's scripts/ folder, as the tests that patch +/// a copy of one read them. Each read takes the file's bytes from disk +/// again. The lines split from them, and which lines set each constant, +/// are kept and reused only while those bytes are the ones they came from, +/// so a script changed on disk is split anew and never read stale. +final class ScriptSource: @unchecked Sendable { + static let shared = ScriptSource() + + static var folder: URL { + // .../Tests/InsomniaTests/TestSupport.swift -> .../scripts + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("scripts", isDirectory: true) + } + + struct NotUTF8: Error, CustomStringConvertible { + let name: String + var description: String { "scripts/\(name) is not UTF-8" } + } + + private struct Kept { + let bytes: Data + let lines: [String] + var hits: [String: [Int]] = [:] + } + + private let lock = NSLock() + private var kept: [String: Kept] = [:] + + /// The lines of scripts/`name`, and for each of `constants` the indices + /// of the lines that set it (those that start with `NAME=`). + func lines(_ name: String, setting constants: [String]) throws -> (lines: [String], hits: [String: [Int]]) { + let bytes = try Data(contentsOf: Self.folder.appendingPathComponent(name)) + lock.lock() + defer { lock.unlock() } + var entry: Kept + if let old = kept[name], old.bytes == bytes { + entry = old + } else { + guard let text = String(data: bytes, encoding: .utf8) else { throw NotUTF8(name: name) } + entry = Kept(bytes: bytes, lines: text.components(separatedBy: "\n")) + } + var hits: [String: [Int]] = [:] + for constant in constants { + let found = entry.hits[constant] ?? entry.lines.indices.filter { entry.lines[$0].hasPrefix("\(constant)=") } + entry.hits[constant] = found + hits[constant] = found + } + kept[name] = entry + return (entry.lines, hits) + } +} diff --git a/Tests/InsomniaTests/UIStartupTests.swift b/Tests/InsomniaTests/UIStartupTests.swift index eec6026c..119493aa 100644 --- a/Tests/InsomniaTests/UIStartupTests.swift +++ b/Tests/InsomniaTests/UIStartupTests.swift @@ -75,6 +75,43 @@ final class UIStartupTests: XCTestCase { return condition() } + // MARK: Over the maximum + + /// Two days typed against the default 24-hour maximum: the pills stay + /// up with the value in them, the label beside them says what fits, the + /// focused pill shakes, and nothing is asked of the manager. Enter on + /// an allowed value then starts as usual. + @MainActor + func testATimePastTheMaximumIsRefusedBesideThePillsWithoutStartingAnything() async { + let rig = Rig() + defer { rig.h.home.destroy() } + XCTAssertEqual(rig.manager.config.maxDuration, 24 * 3600) + rig.controller.expand(mode: .start) + rig.controller.focus(.days) + XCTAssertTrue(rig.model.input.append(digit: 2, to: .days)) + + rig.controller.commit() + try? await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(rig.model.phase, .entering(.start)) + XCTAssertTrue(rig.model.slotsPresent) + XCTAssertEqual(rig.model.startError, "Up to 1d") + XCTAssertTrue(rig.model.startErrorShown) + XCTAssertEqual(rig.model.input.text(for: .days), "2") + XCTAssertEqual(rig.model.rejectBounce, 1) + XCTAssertNil(rig.model.pendingCountdown) + XCTAssertFalse(rig.manager.isActive) + XCTAssertEqual(rig.h.guardFake.calls, [], "nothing was asked of pmset") + XCTAssertNil(rig.manager.lastError, "not a failure of the manager's") + + // Exactly the maximum is allowed and starts. + rig.model.input = DurationInput(days: 1) + rig.controller.commit() + let started = await waitUntil { rig.manager.isActive } + XCTAssertTrue(started) + XCTAssertEqual(rig.manager.session?.endsAt, rig.h.clock.now.addingTimeInterval(24 * 3600)) + } + // MARK: Success @MainActor diff --git a/Tests/InsomniaTests/UIStatusTests.swift b/Tests/InsomniaTests/UIStatusTests.swift index 7206ada9..90cd6fe9 100644 --- a/Tests/InsomniaTests/UIStatusTests.swift +++ b/Tests/InsomniaTests/UIStatusTests.swift @@ -403,24 +403,51 @@ final class UIStatusTests: XCTestCase { @MainActor func testBareEnterStartsTheDefaultPresetButNeverExtends() { let preset: TimeInterval = 4 * 3600 + let month: TimeInterval = 30 * 24 * 3600 XCTAssertEqual( - MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: preset), + MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: preset, maxDuration: month), .run(preset) ) XCTAssertEqual( - MenuBarModel.commitAction(mode: .start, typed: 1800, defaultPreset: preset), + MenuBarModel.commitAction(mode: .start, typed: 1800, defaultPreset: preset, maxDuration: month), .run(1800) ) XCTAssertEqual( - MenuBarModel.commitAction(mode: .extend, typed: nil, defaultPreset: preset), + MenuBarModel.commitAction(mode: .extend, typed: nil, defaultPreset: preset, maxDuration: month), .reject ) XCTAssertEqual( - MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: 0), + MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: 0, maxDuration: month), .reject ) } + /// A time that would end past the maximum session is refused with what + /// still fits, never clamped quietly: the user typed a number and gets + /// it, or hears why not. Extending counts the time already left. + @MainActor + func testTimesPastTheMaximumAreRefusedWithTheAllowance() { + let day: TimeInterval = 24 * 3600 + XCTAssertEqual(MenuBarModel.commitAction(mode: .start, typed: day, defaultPreset: 3600, maxDuration: day), .run(day)) + XCTAssertEqual(MenuBarModel.commitAction(mode: .start, typed: day + 60, defaultPreset: 3600, maxDuration: day), .tooLong(allowed: day)) + // The default preset is held to it too (a preset that outgrew a lowered maximum). + XCTAssertEqual(MenuBarModel.commitAction(mode: .start, typed: nil, defaultPreset: 2 * day, maxDuration: day), .tooLong(allowed: day)) + // 1h left under a 24h ceiling leaves 23h to add. + XCTAssertEqual(MenuBarModel.commitAction(mode: .extend, typed: 23 * 3600, defaultPreset: 3600, maxDuration: day, remaining: 3600), .run(23 * 3600)) + XCTAssertEqual(MenuBarModel.commitAction(mode: .extend, typed: 23 * 3600 + 60, defaultPreset: 3600, maxDuration: day, remaining: 3600), .tooLong(allowed: 23 * 3600)) + // A maximum under the shortest session still allows that much. + XCTAssertEqual(MenuBarModel.commitAction(mode: .start, typed: 60, defaultPreset: 60, maxDuration: 1), .run(60)) + + // The same label the 24h preset chip and the Settings maximum row show. + XCTAssertEqual(MenuBarModel.tooLongText(allowed: day), "Up to 1d") + XCTAssertEqual(MenuBarModel.tooLongText(allowed: 23 * 3600 + 30 * 60), "Up to 23h30m") + // Minutes past a day are kept: the user can type 1d30m back in. + XCTAssertEqual(MenuBarModel.tooLongText(allowed: day + 30 * 60), "Up to 1d30m") + XCTAssertEqual(MenuBarModel.tooLongText(allowed: day + 30 * 60 + 45), "Up to 1d30m") + XCTAssertEqual(MenuBarModel.tooLongText(allowed: 30), "At the maximum") + XCTAssertEqual(MenuBarModel.tooLongText(allowed: -5), "At the maximum") + } + /// The pills can be in start mode over a live session: the user reopened /// them while the start was still in flight. Collapsing then has to show /// the countdown, not pretend the Mac is free to sleep. diff --git a/docs/assets/recovery-flow.svg b/docs/assets/recovery-flow.svg index ef89d6df..655a78be 100644 --- a/docs/assets/recovery-flow.svg +++ b/docs/assets/recovery-flow.svg @@ -1,6 +1,6 @@ Insomnia recovery flow - A simplified diagram of two paths that converge. App cleanup runs at the end of a session or when the app reopens. A separate backup check runs every 60 seconds and leaves a valid, unexpired session alone. Both converge on one step: try to restore verified changes. Only changes that can be verified are undone; failed or unreadable evidence is kept rather than treated as clean. Below that, a warning: may need your attention. Two things can need it: saved audio is restored only by reopening the app, and an unconfirmed freeze means you should inspect the process yourself. The backup check undoes recorded changes only. It is not battery or thermal monitoring and cannot restore audio. On the left, an illustrated example of the right-click menu while cleanup is still pending shows a warning line reading Sleep still held with no session, a status line, then Settings and Quit Insomnia. It is an illustration, not a screenshot. + A simplified diagram of two paths that converge. App cleanup runs at the end of a session or when the app reopens. A separate backup check runs every 60 seconds. It ends a session before its deadline when Insomnia is not running, when the Mac runs on battery below the end floor, or when the Mac is at critical heat with thermal rules on, reading the end floor and thermal rules from the same settings file as the app. Otherwise it leaves a valid, unexpired session alone. Both converge on one step: try to restore verified changes. Only changes that can be verified are undone; failed or unreadable evidence is kept rather than treated as clean. Below that, a warning: may need your attention. Two things can need it: saved audio is restored only by reopening the app, and an unconfirmed freeze means you should inspect the process yourself. The backup check undoes only recorded changes, and it cannot restore audio. On the left, an illustrated example of the right-click menu while cleanup is still pending shows a warning line reading Sleep still held with no session, a status line, then Settings and Quit Insomnia. It is an illustration, not a screenshot.