Repository navigation
Conversation
…low the end floor, or heat is critical
Sleep stayed disabled until the journaled deadline whenever the app
stopped running. The launchd agent only restored once endsAt had passed,
and the battery and thermal floors lived in the app alone, so a crash,
force-quit, or hang under a closed lid left the Mac awake with no cutoff
but the deadline, which could be 30 days away.
The app now holds an exclusive flock(2) on APP_SUPPORT/.app.alive from
launch (before reconcile) until the process exits; the kernel releases it
however the process dies. backstop.sh probes that lock without waiting
(lockf -t 0, exit 75 means held) before it decides a valid session may
stand, then reads pmset -g batt and notifyutil's thermal pressure level.
A valid session is ended exactly as --force would, with the reason in
the log, when no process holds the lock, when an internal battery is
present and the Mac draws from it below endFloor from config.json
(default 10, strict; a present but unreadable battery or a failing pmset
also ends), or when the thermal level is 3 (trapping) or above with
thermalRules on. An unreadable thermal level only warns. --force runs
none of the probes, so install.sh and uninstall.sh are unchanged.
Config.maxDuration defaults to 24 hours (decoder default too) and the
3-day preset is gone. A typed time or default preset past the maximum is
refused beside the pills ("Up to 1d", or "At the maximum" while
extending with nothing left) instead of being clamped quietly. The
extension allowance uses the manager's clock, exposed as
SessionManager.now, since the controller used the wall clock and tests
inject a fake. The Days tooltip names the configured maximum.
Docs: README battery rules and recovery, spec sections 1, 6, 8, 11 and
the manual test plan, SECURITY.md on the lock being holdable by any
same-user process, and four new "Not run" rows in release-validation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck config, keep the alive lock retry going
Review follow-ups on the out-of-process cutoff.
A second Insomnia that found .app.alive held gave up after 2 s and never
tried again, so once the first instance exited the backstop ended the
second one's session as "Insomnia is not running". The delegate now keeps
trying every 2 s for the life of the process (AppAliveLock
.acquireEventually), and the log says so.
The app never re-read session.json, so a session the backstop ended
while the app was stopped, hung, or not holding the alive lock stayed
live in memory: the countdown kept going, an extend wrote the session
back without sleep disabled, and lid actions ran for a session that was
over. Every transaction but an end now checks, under the recovery lock
right after the journal is read, whether session.json is still there
while a session is held in memory; if it is gone the app ends on its
side from the journal (EndReason.agentCutoff), so anything the agent
could not undo is retried and observers and timers stop. The 1 Hz
countdown tick does the same check with a stat first, so an open-lid
session is dropped within about a second without a transaction of the
user's.
Settings saves the whole Config, so ordinary config.json files from
older builds hold 30 days and the 3-day preset as explicit values and
would have kept them. The decoder reads exactly those legacy defaults as
the current ones and keeps any other value.
backstop.sh reads endFloor and thermalRules with plutil -type as well as
-extract, so a string "30" or "false" falls back to the default here as
it does in the app. It reads endFloor before pmset: with the floor at 0
nothing is read, so a failing pmset cannot end a session the user took
out of the rule. The pmset and notifyutil reads go through run_bounded
(new run_bounded_read captures stdout), so a hung read cannot hold the
recovery lock for the whole minute; a hung battery read counts as
unreadable and ends, a hung thermal read only warns. A read left alive
does not set command_alive for the undo commands that follow.
The allowance label and the Days tooltip use every unit floored to the
minute ("Up to 1d30m"), not chipLabel's "1d". The README illustration's
alt text and a caption now say what the backstop does; the drawing
itself still needs redrawing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings in #31 (CI: bash 3.2 syntax check, release build with warnings as errors, workflow lint), #16 (private unified-log bodies), #29 (battery unknown cutoff) and #37 (README badge). README battery rules conflicted: main added the app's unreadable-battery end, this branch said the ends do not need the app. The merged paragraph keeps both. Spec section 6 said "the two ends" are enforced by the backstop; with main's unreadable-battery end there are three, and the backstop ends on the first pmset read it cannot use where the app tolerates one IOKit miss, so that sentence says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The migration read an older build's stock config.json as the current defaults: the 30-day ceiling became 24 hours and the stock preset list lost its 3-day entry. A default left at that 3-day preset stayed at 3 days, so bare Enter answered "Up to 1d" instead of starting. When the user never set the ceiling (the legacy 30 days or no key), a default above the new ceiling now moves to the largest preset left under it, or to the 4-hour stock default if none is. Presets above that ceiling are dropped as well: Settings refuses to add them, and picking one as the default would fail the same way. A ceiling set by hand keeps the presets and default as they were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by hand stays The migration read any maxDuration of exactly 30 days as an older build's stock value and replaced it with 24 hours. 30 days is also the Days pill's limit and the likeliest value to type into config.json, so a ceiling set by hand was lost at the next launch and written to disk by the next Settings change. Current builds now write configVersion 2, and only a file without it is migrated. SessionManager writes such a file back once at launch, so an upgraded install that never saves from Settings is marked current too and a ceiling typed into it afterwards is kept. A current file is read, not rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #26 (reconcile leaves a SleepDisabled bit Insomnia did not set). No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The script tests stood in for a running app with a lockf process that held .app.alive for 30 s. A slow run (the suite under load, a test with several backstop runs) could outlive it, and the next run would then see no app and end the session for the wrong reason. holdAliveLock now takes the lock with AppAliveLock, the type the app uses, in the test process, and the test releases it in its defer. There is no timer left to run out. holdLock (the recovery lock holder for the contention tests) is back to the inline body it had on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reads went through run_bounded, whose supervisor keeps fd 9 so that a live undo command keeps the lock. A read that ignored SIGTERM was left running with that descriptor, and every later run then timed out on the lock (exit 75) and could not end the session for any reason. run_read starts the read from a supervisor that closes fd 9 first, so neither holds the lock. A read that ignores SIGTERM gets SIGKILL: it is unprivileged and has nothing to leave half done. The undo commands keep run_bounded and the lock as before. The new test uses a notifyutil that ignores SIGTERM, leaves a child behind and records whether it or its supervisor had fd 9. The lock is free right after the run and the next run ends the session. Without the fd 9 close it fails with exit 75. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backstop removed session.json only after the undo cleared the whole journal. When the undo was partial (saved brightness only the app can restore, a failing or hung pmset), the run exited 1 and left a session that still read as valid. A relaunched app resumed it and disabled sleep again, and an app that was alive but stopped never adopted the end, because it waits for session.json to disappear. A run that ends a valid session (a cutoff or --force) now removes session.json under the lock right after the decision, before it reads the journal or undoes anything. What the undo cannot finish stays in state.json; the next run, or the app's reconcile with no session, completes it. Expired sessions keep their old handling. The app's 1 Hz tick can now see the end while a hung undo command still holds the lock. After a lock wait fails it waits recoveryRetryDelay before trying again, instead of one 10 s wait and one log line every second. Tests cover a partial undo for a dead app, a failing pmset with the next run finishing the job, --force with a failing pmset, a hung undo, and an app relaunched after a partial end that restores instead of resuming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec quoted the Hours and Minutes tooltips as "0–23" and "0–59". The repository's writing rules allow no en dashes in docs, and Greptile's summary of the last review flagged the line. It now names the ranges in words; the tooltips themselves are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that ended a valid session but could not remove session.json (an immutable file) still restored the journal and could clear it. A relaunched app then read a valid session and disabled sleep again, despite the cutoff. Stopping the run there is no fix: it would skip the undo and leave sleep disabled with no app to restore it. The end is now made durable instead. When the removal fails, the run copies session.json's bytes to ended-session.json and goes on with the undo. While the two files match, the session is over whatever endsAt says: the app's reconcile restores it instead of resuming it, the 1 Hz tick and the next transaction end an open session, and every later run ends it again without the checks and retries the removal. The two files go together once session.json can be removed, and a record that matches no session.json is removed. The app writes the same record when its own end cannot remove the file. If the record cannot be written either, nothing on disk says the session is over. The run still restores sleep but keeps sleepDisabledByUs journaled and exits 1, so the journal reads dirty and uninstall stops until a person makes the file removable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retry-delay test ran on a real clock, slept 3.2 s and expected one failed lock wait by then. On a loaded machine the first wait may not have finished in that time, and the later steps also depended on wall-clock sleeps. The test now calls noticeAgentEnd itself on the harness, whose fixed clock keeps the real 1 Hz timer from firing: one tick with the lock held, ticks after its release that the delay suppresses, and the tick at the delay that ends the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeout paths in run_read and run_bounded used the shell's kill builtin. Every process tool the script calls goes through its fixed path variable, and the tests replace that path with a fake that records the call. The fake kill now passes -TERM and -KILL on to /bin/kill only for a pid in one of the run's own .backstop.*.pid files, so a hung fake command still stops and no other pid is signalled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #44, #24, #35, #42, #27 and #45. Conflicts, resolved so both sides keep their behavior: - README battery rules: this branch's sentence that the backstop runs the ends without the app, then main's end floor ordering text. - InsomniaApp: the alive lock and main's LoginItem are both kept. - backstop.sh tool block: NOTIFYUTIL and CMP from this branch, DEFAULTS from main; the time limit comment names both pmset and defaults. - spec section 8: main's appNapOverrides bullet, then this branch's agent bullet. - RecoveryScriptTests: the fixture patches NOTIFYUTIL and DEFAULTS, the header lists both, and the writeConfig helper both sides added is kept once. - TestSupport: setImmutable from this branch, then main's runUntilSuspended and its settleQueuedRequests comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An end refused because the journal could not be read set pendingEnd and scheduled nothing. If the agent then ended the session, the tick adopted that end through performEnd, but only end() cleared pendingEnd. Every later Start was refused until another end request came, and none would. performEnd now settles the pending end itself when it restores, or when it leaves the rest to an armed agent: it clears pendingEnd and invalidates the retry timer, which is obsolete by then. end(), the adoption of an agent's end and reconcile all reach that code. An end refused for an unreadable journal now schedules the same retry as one refused for a held lock, so it ends the session once a person repairs the file. Its notification still goes out once per error. Tests: an end refused for an unreadable journal ends the session at the retry once the file is repaired, with one notification. An adopted agent end, whether it restores or leaves the restore to the armed agent, settles an end pending on an unreadable journal, and a later start runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake thermal read in testReadThatIgnoresSigtermLeavesTheLockToTheNextRun ran lsof on its supervisor before it stopped answering. The fixture gives each read 1 s, and lsof can take longer than that on a busy runner, so the read could be killed before it wrote its result and a correct run failed the test. The descriptor check is now its own fake mode, CHECK_FD9. It records whether the read or its supervisor has fd 9, then prints a normal level. Its test raises the fixture's command limit to 30 s with a new setCommandTimeout and asserts that the read finished in time. The SIGTERM test keeps its SIGKILL, free-lock and next-run assertions and no longer looks at descriptors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Greptile comment 4169235256: when session.json went but ended-session.json could not be removed, the backstop dropped the error, and uninstall.sh --purge neither removed the record nor said it was left. A purge could report success while a copy of the session's times stayed in Application Support. backstop.sh removes the record through one helper, used with session.json and for a stale record, that logs a warning naming the file when rm fails. The app's Store.deleteSession logs the same way. uninstall.sh removes the record after its backstop run, with or without --purge, and prints a "Kept" line naming it when it cannot. Once its session.json is gone the record ends nothing, so none of these fail. Tests: the backstop logs a stale record it cannot remove, and so does the app's end. A purge removes a record. A purge over an immutable record exits 0, keeps the file, says so, and removes the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec said the agent runs before the app at login and ends a session the app left valid. Nothing orders the two. With launch at login on, the app can take the alive lock first, and its reconcile resumes a valid session as on any launch (step 2). Codex flagged the sentence as a guarantee the code does not give. Nothing in the spec, README or app requires a reboot to end a session, so the docs now state the real outcome instead of adding a boot identifier. With launch at login off, the agent ends the session at login. With it on, whichever runs first decides, and a session the app resumes lasts until its deadline or a cutoff. The release validation row is split into those two cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #46, #36, #23 and #17 (main at 190ad0a). Conflicts, resolved so both sides keep their behavior: - Paths and Store: the end record helpers from this branch and #23's move-aside helpers are both kept. deleteSession still removes the record and logs one it cannot remove. - SessionManager.performEnd: #23's retainedBecause flow, with this branch's record written in its deleteSession failure case. The reason says whether a relaunch would resume the session. - backstop.sh: the session note keeps this branch's early-end wording and adds #23's unreadable state. The clean-journal path keeps this branch's exit for a valid session it already handled and its checked remove_session. The final step keeps this branch's check for a session.json it could not remove, then #23's quarantine of an unreadable one. - uninstall.sh: #23's remove_owned removes ended-session.json with session.json, with or without --purge, and names it and exits 1 when it cannot. This branch's separate helper that printed "Kept" and exited 0 is gone; its test now expects remove_owned's report. - RecoveryScriptTests: the header lists notifyutil and cmp beside #23's date note; holdAliveLock and #46's LockHolder are both kept. testCompleteSessionWithAFutureEndsAtIsValid, from #23, now holds the alive lock as a running app does, since this branch ends a valid session nobody holds it for, and expects the two reads. - release-validation: this branch's backstop and reboot rows, then #23's moved-aside rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#23 put rm, mv, cp, date and mkdir behind fixed-path variables at the top of backstop.sh, so tests can patch them and nothing runs through PATH. The code this branch added (run_read's cleanup, the end record helpers, remove_session and record_end) still called rm and mv by bare name. They now use "$RM" and "$MV" like the rest of the script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#23 made Store.read refuse a file that is not a regular file before opening it, because open(2) on a FIFO blocks while the app holds the recovery lock on the main actor. sessionEndIsRecorded and recordSessionEnd read both files with Data(contentsOf:) directly, and the 1 Hz tick calls sessionEndIsRecorded whenever a record exists. The check now lives in Store.readData, which read and both record functions use. A FIFO at session.json reads as not recorded and is never opened. On macOS 26 Data(contentsOf:) also refuses a FIFO without opening it, so the new test passes without the check too; it pins the behavior rather than the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex found two places where the backstop judged a session by different
inputs than the app.
A pmset -g batt without an InternalBattery line counted as a desktop.
The power source list can lose a laptop's battery row, which is why
PowerMonitor.classify also looks for the AppleSmartBattery service in
the I/O Registry. With the app stopped and the row missing on every
read, a closed laptop stayed awake with no floor until its battery
died. The backstop now asks ioreg for the same service when the row is
missing. No service is a desktop, as before. A service without a
charger reported (ExternalConnected = Yes) ends the session as an
unreadable battery, which is the app's rule too. An ioreg that fails or
hangs cannot show a desktop, so it ends the session like a failing
pmset.
config_int took only JSON integers, but JSONDecoder reads any number
that is exactly an integer as an Int, so {"endFloor": 30.0} is a 30%
floor in the app and was 10% here, and 0.0 turned the rule back on. A
float now counts when it is whole. plutil's raw form rounds to six
places, so the check reads the XML form, which prints the shortest
exact value. The floor is also clamped to 0...95 as
Config.normalizeFloors does.
The fixture fakes ioreg (IOREG) and gains clearLog, so a loop's case
cannot pass on a line an earlier case logged.
Tests: a missing row with the service on battery, without the key,
with a failing or hung ioreg ends the session, and with a charger it
stays; a desktop runs the ioreg read and keeps the session. 30.0 and
3e1 are a 30% floor, 30.5 and 30.0000001 fall back to 10, 200 is 95,
and 0.0 and -5 turn the rule off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver it Greptile found that a hand-edited "configVersion": "2" made the whole file fail to decode, and the launch then saved the default settings over it, so the user's settings were lost for good. The marker now counts by its presence, as Store.configHasVersion already did; its value is never decoded. That fixes the quoted case, but any other bad value or a JSON typo still reached the same overwrite. So a config.json that does not decode, or cannot be read at all, is now renamed to config.json.unreadable-<UTC stamp> (the shape and helper #23 uses for session.json), and only then are the defaults written. If the rename fails nothing is written over the file, and the app runs on defaults in memory. The first reconcile posts a notification naming the copy; init runs before the app has finished launching, and a second copy that never reconciles stays silent. uninstall.sh keeps these copies, as it keeps config.json, and --purge removes them with the same exact-shape check as session.json copies. The collect function takes the base name for that. Tests: a "2" marker keeps every setting and leaves the file as it is; a wrong-typed value and a truncated file are moved aside with their bytes, defaults are written and one notification names the copy; a rename that fails leaves the file untouched. Uninstall keeps two config copies, names a directory with such a name, and purge removes only the two. Reverting the marker check, the rename, or the config loop in uninstall.sh each fails these tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Greptile found that a second Insomnia that could not take .app.alive still reconciled and could start a session. If it then crashed, the backstop saw the first copy's lock and left sleep disabled until the deadline. Launch Services keeps one instance, but open -n or running the binary directly gives two. LaunchGate now takes the alive lock before anything else runs. Only with the lock does the launch go on: login item check, Settings window, status item, then reconcile. A copy that cannot take it within 2 s, or cannot open the lock at all, posts a notification saying why, waits until the system has it (Notifier.postBeforeExit), and quits. Quitting such a copy ends nothing: applicationShouldTerminate returns at once without the lock, because an end there would restore the journal of the copy that holds it and end that copy's session. The old fallback, which kept retrying the lock every 2 s while the copy ran, is gone with AppAliveLock.acquireEventually and its tests; no copy runs without the lock any more. SessionManager.notifier is internal so the gate can post through the app's notifier before anything starts. Tests: with another holder, the gate neither starts nor reconciles, no pmset call runs, the dirty journal and session stay as they are, and one notification names the lock; a directory at the lock path stops the launch the same way; with the lock, start runs before reconcile and reconcile restores the journal; a 150 ms hold like a backstop probe is waited out. Letting the gate start and reconcile without the lock fails 10 assertions. The quit guard is one line in AppDelegate, which has no unit test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #21 (tmux nudge opt-in: tmuxNudgePressesEnter in Config, TmuxNudge, Settings and their tests). No conflicts. The new key is decoded per key like the others, so the configVersion presence check and the move-aside of an undecodable config.json cover it unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AppNapTests.testJournalWriteFailureMeansNoPreferenceWrite failed on 7bab185, locally and on hosted CI. It made state.json immutable before reconcile and expected the session to resume with only the App Nap entry refused. Since 7cede36 (F3), reconcile writes the journal before every resume, even when sleepDisabledByUs is already set, so a session the agent ended cannot resume while the journal cannot be written. That write now fails first, the resume is refused and the session ends. This is the intended rule, so the test changed and the code did not. The test now holds reconcile at `disablesleep 1`, after the resume's journal write, and makes state.json immutable there. The App Nap write is then the first one that fails, and every assertion stays. The session runs, no preference is written, and the error names the App Nap setting and says it was left unchanged. testUnwritableJournalAtReconcileResumesNothingAndWritesNoPreference keeps the old setup and checks the refusal. The session is not resumed, sleep is not held again, no preference is written, and the log says the sleep guard could not be journaled. With the reconcile write made conditional again, as before 7cede36, the new test fails three assertions and the updated one still passes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The local full run at be4b4c8 failed testALiveCommandIsReportedOnTimeWhenEveryPollIsSlow with two "sudo SIGTERM" lines where the supervisor sends one. #50's supervisor signals the command once, by jobspec, to its pid alone. The second line came from the fake. hang_on_term's wait loop ran `$(date +%s)` in every condition. Bash 3.2 starts a command substitution with the shell's pending traps and trap commands (reset_signal_handlers keeps both), and the child runs pending traps before its first command (execute_cmd.c:508). A SIGTERM that lands after the condition's own trap check and before that fork runs the trap in the child and again in the shell, so it is logged twice. The watchdog now runs on bash's SECONDS, and the wait forks nothing but /bin/sleep. This also removes the `date` that e52ea07 moved ahead of the pid, so a group signal finds nothing in the setup to kill. No assertion changed. A standalone bash 3.2 script sends one SIGTERM while a command substitution is pending in the same command. It ran the trap at BASH_SUBSHELL 1 and then 0 in three runs of three, and once with SECONDS. With a 0.2 s command substitution added before the time check, the old loop failed the test in three runs of four with the same two lines, and the new loop passed six of six. On the final code both tests that count signals passed five runs of five, and all six tests that use hang_on_term passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dFloor over the whole Int range R1: an agent end that could remove session.json and record the end nowhere restores sleep and keeps sleepDisabledByUs. After state.json alone was made writable again, a relaunch resumed that session and ran disablesleep 1. Reconcile now reads pmset -g before it resumes a journaled hold. Only an end clears the bit, and none can run beside reconcile, so a 0 means the hold was undone while no Insomnia ran; the session is ended through performEnd, which records the end where it can. A read that fails ends it too. R2: Swift decodes any Int for endFloor and clamps it to 0...95, but config_int fell back to 10 past 18 digits. config_int now takes min and max and clamps the whole signed Int range, comparing 19-digit integers in two halves and plutil's exponent floats by exponent and 17 digits, without shell arithmetic on a value past 18 digits. Tests: the reviewer's repair-and-relaunch case for state.json alone and for every file, an undone and an unreadable hold at reconcile, the agent following the app's floor for integers and floats across the Int range, and Int.max and Int.min in an immutable config with a normalized control. Relaunch fixtures that journal a hold now set the fake's SleepDisabled to 1, as a crash leaves it, and exact call lists include the new pmset -g read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d endFloor below the default Review of 953b4b0 found two problems. R1. With session.json, an unrelated ended-session.json and state.json all immutable, an end (the agent's or the app's) recorded nothing. Round 19's check ends such a session at relaunch when pmset reads SleepDisabled 0, but a failed restore or another program's hold leaves the bit at 1, and then a relaunch after the journal was repaired resumed the ended session. A 1 says nothing about the session, so the end is now recorded instead: when the three files refuse the write, the copy of session.json goes to a new file beside them, ended-session.json.<8 letters or digits> (mktemp in backstop.sh; O_EXCL and a random name in Store), kept only when it reads back identical. Reconcile, the tick, adoption and the agent's already-ended check honour a regular file of that shape with session.json's exact bytes. Every agent run removes one whose session.json is gone or holds other bytes and keeps it when session.json cannot be read. deleteSession and uninstall remove them. A folder that takes no new file is the one case left, and the docs say so. R2. plutil rounds an endFloor of -9223372036854775809, which the app's decoder rejects, to -2^63, and config_int clamped that to 0, so a hung app kept 10% while the agent enforced nothing. Other JSON5 forms (+5, 5., 0x5) also read below the app's floor. config_int now reads the number's own text with a strict JSON tokenizer (config_number_text) and uses it only when app_int_value proves the decoder takes it as that Int; any other text gives plutil's reading but never less than the default. Docs: the reboot paragraph is conditional on SleepDisabled 1 surviving the reboot (not measured), and the README, spec and backstop header no longer say an agent end can never leave a session to resume. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n the log folder Round 23 review of bf71148, four findings. R2, R3 and R4: the backstop parsed config.json itself, so a duplicate or escaped top-level key, a file the app rejects for another field, and a number the decoder rounds (1e-400, 4.9999999999999999) gave the agent other cutoffs than the app. backstop.sh no longer parses the file. While a session is valid and the app holds the alive lock, read_cutoffs passes config.json's bytes on standard input to the installed binary's new one-shot mode, `Insomnia --agent-cutoffs <seconds>` (AgentCutoffsCommand, answered in main.swift before AppKit starts). It decodes them with Store.decodeConfig, the decoder loadConfig uses, prints the clamped end floor and the thermal rule, and exits. It takes no lock, opens none of Insomnia's files and writes nothing. It runs as a bounded read with fd 9 closed, and only when Info.plist declares InsomniaAgentCutoffsVersion 1. A missing, non-regular or unreadable file, or "rejected", gives the app's defaults (10%, on). Any other outcome gives the strictest values (95%, thermal rules on) and a log line naming the cause. CutoffAgreementTests' table is back to one expected value per row, compared for equality. R1: an end the agent could record nowhere was resumed once the folder and state.json were repaired. When the folder beside session.json takes no new file, the agent and the app now write the record aside in the log folder (~/Library/Logs/Insomnia) and read it back before the undo. The log folder counts only while it is a directory this user owns and not a symlink, a record only when it is a regular file this user owns. Reconcile, the tick, the agent and uninstall.sh search both folders. When neither folder takes a new file, reconcile now replaces session.json with the same bytes before it holds sleep and ends the session when that fails. Cost: a crash while session.json cannot be replaced ends the session at the next launch. Limit left: once session.json, the folders and state.json all take writes again, an app launched before the next agent run with SleepDisabled 1 resumes such a session; nothing on disk tells that end from a crash. Docs: README, spec sections 6, 8 and 10, SECURITY.md and five "Not run" rows in docs/release-validation.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…offs (round 25) Round 25 review of 252557d (three P1 findings) and Greptile 4217047024. R1, an end nothing records: when ended-session.json, the journal and both folders refuse, the agent and the app write `ended-session-v1 <base64>` into the recovery lock file in place (inode kept, never unlinked). The app and the agent read it the same way: none, a whole record (ends only those exact bytes) or unknown content (ends any session.json until it is gone). Reconcile, the tick, adoption, every transaction, every agent run, Start and uninstall.sh in both modes read it. A full repair after the reviewer's three-immutable-file case no longer resumes, for a failed restore and for a later hold. R1, the reader of ended-session.json: backstop.sh and uninstall.sh remove it only when session.json is gone or cmp reports other bytes (exit 1); while cmp cannot compare, it stays and ends nothing. R3, the cutoffs of a hung session: the journal records `sessionCutoffs` ("30 false"). Start and reconcile write it, transactions and the tick record it again when it differs, and a Settings change during a session records it under the recovery lock before config.json is saved. With config.json rejected or missing, the agent reads it through `Insomnia --agent-session-cutoffs` (version 2): none gives the defaults, a value the app does not write or an unreadable state.json gives 95%/on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts, resolved to keep both behaviors: - SessionManager.performEnd: an incomplete end settles the pending end only when main's agentCanFinish holds (armed, no hidden failed restore, no owed lit read, no owed settlement); otherwise it schedules the retry. Both settlePendingEnd and journalNeedsRestore stay. - RuntimeState: undoEntries leaves out main's kept-display records and this branch's endedSession and sessionCutoffs; CodingKeys list all. - backstop.sh / uninstall.sh journal_shape_problems: endedSession type check and main's kept-display checks with record_text_problems. - backstop.sh clean-journal exit: main's refused-brightness note, then this branch's session removal through remove_session. - uninstall.sh purge and non-purge: state.json stays while it holds a refused brightness (main); ended-session.json, records aside and the lock file record are removed or emptied (this branch). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts holds sleep #34's reconcile ends a session whose journaled sleep hold reads 0 at launch. Main's EarlierBootLowPowerClaimTests seeds a running session over boot A's journal without boot A's hold, so the three in-session routes ended at reconcile. The fake now reads the hold for those routes, as a restart keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tten; run the end floor tables in parallel Four end tests deny new files in the folder that holds the agent's status files, so each run waited the production 30 s limit plus grace for a status that never comes (about 38 s per test). Their PatchedBackstop copies now give the fake commands 2 s. Every assertion stays: the run still exits 1 on the missing status, records the end before the undo, keeps the lock inode, and a relaunch neither resumes the session nor holds sleep again. The Float and Integer end floor tables keep every row, the app's decoder on each and both agent runs per row (end one point below the floor, keep at it), but each run now gets its own home (SeparateRun) so eight run at once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Greptile 4219151895. backstop.sh took cat, grep, head, tr, stat and id from PATH in its readers: run_read's answer file, session.json's readability and shape, the end record copies, the battery and ioreg rows, the publish check, the --resume-frozen excerpt, its uid and the App Nap probe. #34 added the cat in run_read, both grep battery reads, the two end record copies, the cutoff answer excerpt and one head check; the rest came from main. uninstall.sh (all from main) took cat, head, tr, awk, id, basename and dirname by name. Each now uses CAT, GREP, HEAD, TR, AWK, ID or STAT, or parameter expansion for the folder names. sleep stays by name: it reads nothing, and main's slow-poll tests replace it through PATH. PathSubstitutionTests and testUninstallAndItsBackstopTakeNoToolFromPath run the real scripts on twin homes, once with the usual PATH and once with stand-ins first in PATH that would answer a 0% floor, a full battery or a file that is not JSON. Both runs match and no stand-in is called; on the previous scripts both tests fail. PatchedBackstop's fakes now call /bin/cat, and failLockReadBack fails only the lock file read instead of every CAT. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed rollback (round 27) - backstop.sh checks that state.json loads as the app loads it before it reads the cutoffs for a valid session or ends one, --force included. A journal that fails the check, or that the app's binary rejects, stops the run with session.json, the journal and every undo entry kept (exit 1). record_text_problems, the same in both scripts, now reads every object and array: a key of letters twice in one object (escapes decoded, a Kelvin sign read as K), bad escapes, values that are no JSON value, and Float, Int32 and Int64 ranges where the app reads them. - Insomnia --agent-session-cutoffs (InsomniaAgentCutoffsVersion 3) decodes the whole journal first (Store.decodeState). It answers rejected for a journal the app does not load and foreign for a sessionCutoffs the app does not write. - When the app's binary cannot answer, the agent reads sessionCutoffs from the journal it checked (journal_cutoffs) and enforces that record instead of the strictest cutoffs. A missing record gives the defaults when config.json was missing or rejected, and the strictest when only the binary failed on config.json. - updateConfig: when config.json refuses a cutoff change after the record was written and the record cannot be put back either, the session ends on disk under the held lock (endSessionOnDisk, shared with performEnd), then in process; Settings says so. - Docs: spec sections 6, 8 and 10, README, SECURITY.md, Greptile rules and a release validation row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Quote the "colon" state in record_text_problems (SC2209), and say why its equal-length digit strings are compared as text: the int64 limits overflow $(( )) (SC2071). The awk program's $0 in uninstall.sh's agent app list is awk's own (SC2016). No behavior changes; both copies of record_text_problems stay byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h has them 958ba1a changed two uninstall.sh lines that main's tests compare word for word with install.sh, and the full run on 277b77f failed both tests: - SCRIPT_DIR found the script's folder by parameter expansion instead of dirname (ReleaseWorkflowTests.testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwn). - bounded() read a sudo call's pid with "$CAT" instead of cat (RecoveryScriptTests.testInstallAndUninstallShareTheBoundedCallHelper). Neither reads state: one finds the script's own folder, and the other is reached only by a bounded sudo, which uninstall.sh never runs. Both now have main's text again, and the comment on the tools taken by name says so. PathSubstitutes no longer puts a dirname stand-in first in PATH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…run fixture rows at once - End record in insomnia.log. When neither folder takes a new file and the recovery lock file takes no write, the app (LogEndRecord) and the agent (record_end_in_log) append one line, insomnia-ended-session-v1 <size> <base64>, under the recovery lock, and count it only once it reads back as a whole line. The app, the agent and uninstall.sh look for that line in insomnia.log and .1. The app rotates insomnia.log only under the recovery lock and copies a record in force forward; the backstop never rotates. uninstall --purge removes the logs only once session.json is gone. - Journal check. check_journal follows every object and array but checks only what the app decodes. It accepts whole numbers written with a fraction or exponent up to 2^53, duplicates and escapes where the app reads nothing, and UTF-16. It still refuses a known key twice in a checked object, a number a Double rounds, plutil-unparseable forms, a lax "1." under sessionCutoffs, NUL and UTF-32. - Script provenance. uninstall.sh finds its folder with script_dir(); the bare cat in bounded() stays unreachable. - Lock record states: none, record, foreign, unreadable. Content read whole that is no record ends no session and the agent empties it; an unreadable lock file still counts as an end. A lock file over 1 MiB no longer refuses a start. - Tests: twelve tables now run each row in its own home, eight at a time. The nine on ScriptFixture give their scripts the production limits, since no fake there hangs; the two hung-binary rows wait 5 s. Every row and assertion is kept. - Docs: README, spec sections 6 and 8, SECURITY.md and the Greptile rules describe the log record, its rotation, the narrower journal check, and that a record matches bytes, not a session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…empty it Greptile 4224841021 on 277b62a: a writer stopped partway through the lock file record left content that read as foreign, which ended nothing, and the agent emptied it before it checked for an end. A relaunch after a full repair could then resume a session whose end had been recorded only there. Both readers (Store.lockHoldsRecordCutShort, lock_holds_record_cut_short) now count the record of the bytes in session.json cut short as a writer leaves it, its first bytes or the whole record with old bytes after it, as that session's end. Other content that is no record still ends nothing. No writer empties such content: the app writes over it, which only adds to it before the cut; the agent appends the rest to the first bytes, leaves the whole record with bytes after it and an unreadable file as they are and goes on to the log, and uses `>` only over content that ends nothing. The agent keeps the content while session.json cannot be read. Tests stop the app's real writer partway with a file size limit, prove the agent appends with an append-only flag set mid-run, relaunch after both cut-short forms, and check a new session still resumes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…onfig.json itself Independent review of f2298fe (round 30), items R30-1 to R30-6. - Lock file writers (R30-1). RecoveryLockHandle.replaceContents keeps the bytes the file shares with the start of the record (pread), cuts the file to them and appends the rest, so a writer stopped partway leaves the old bytes, an empty file or the record's first bytes, never those bytes over old bytes that differ. The agent's record_end_in_lock does the same with >> and >. An unreadable lock file is never written over; both go on to the log. A relaunch that resumes a session empties the lock file first. - Start (R30-3). Store.settleLockForStart runs before the new session.json is written: it keeps a whole record of the session.json being replaced, completes that record's first bytes, and empties anything else, so a stale prefix cannot end the new session. The replaced file's record goes last, after the journal. A failed start puts the journal, the old session.json and the lock file's exact bytes back, the lock file before the old session.json. An unreadable lock file beside no session.json is emptied and not put back. - Log lines (R30-2). A record at the end of insomnia.log that lacks only its newline counts, so every writer reads the last byte first and puts a newline before its own line when that byte is not one or cannot be read: OwnerOnly.appendToLog and LogEndRecord in the app, log and record_end_in_log in the agent, and the LaunchAgent's refusal line (agentProgram and install.sh's AGENT_PROGRAM, with tail -c 1). - Journal check (R30-4). In a frozen process, startedAtMicros is checked only after a non-null startedAt and bootSession only after both, as FrozenProcess decodes them. Whole numbers written with a fraction or an exponent pass where the type holds them and a Double holds them exactly (1e18 for an Int64). UTF-32 without a byte order mark, or UTF-32BE with one, is read through iconv. A sessionCutoffs written twice or with an escape JSON does not have is a foreign record, not a malformed journal. - Cutoffs without the binary (R30-5). When the binary cannot answer for config.json, the agent reads the file itself (config_cutoffs): a bounded copy of at most 64 KiB, parsed by plutil and passed by record_text_problems in its config form, which also checks each value's type. A foreign record and a state.json that is a symlink to nothing count as no record, as the app reads them. With no record the defaults apply while config.json is missing or rejected, and 95%/on only while the file is there but neither reader can read it. Both stay open. - R30-6 is not changed in code. The docs now name the stop before the first byte of a record as a second history a relaunch resumes, and say that refusing a resume while the log takes no line would not close it. Tests cover each writer and reader: lock writers stopped partway (a file size limit, an append-only flag, a read failure), start and rollback orders, log tails for the app, the agent, adoption, two rotations, the LaunchAgent line and both uninstall modes, the journal tables with the new rows, provisional frozen entries, and config.json read without the binary. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… written as 1., and docs name every end that has not counted Full run 1 on 159a570 failed two policy tables on their own expectations, not on the agent: - A run with no state.json, or a symlink to nothing, ends the session without the restore call, since no journal records a sleep hold to undo. The tables expected the call on every end. They now expect it only when the journal holds the hold (JournalForm.holdsSleep). - A run that stops on an unreadable journal stops before it asks the binary, so it logs nothing about the binary. The tables no longer expect that line there. The agreement and reader tables gain a sessionCutoffs written as 1.: the app loads the journal, the binary answers foreign, and both scripts accept it with the agent's reader taking it as foreign. README and spec section 8 now say that an end stopped before any record of it counts (before session.json is removed and before a record is whole, or before the first byte of one in the lock file) records nothing, which covers a partial aside or log line, not only a stop before the lock file's first byte. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… lands inside an accepted record Greptile 4227512547 (P1): an accepted End record in insomnia.log could be broken by another writer's line landing between its parts. Every writer of the log now takes flock(2) on the log file itself, innermost after the recovery lock and the app's own NSLock, checks after locking that its descriptor is still on the file the path names, and reopens at most four times when it is not: - The app's Log.append and OwnerOnly.appendToLog wait up to 2 s. A line whose lock is not taken in time waits in memory (64 KiB, oldest whole lines dropped first) and goes out before the next line that gets the lock. - LogEndRecord.append waits the same way and writes nothing when it cannot lock: the record then does not count. - backstop.sh's log and record_end_in_log use /usr/bin/lockf -s -t 5 on the log's descriptor. A line not locked in time goes to stderr, saying so; a record not locked in time is not written. - The LaunchAgent's own line (AGENT_PROGRAM, byte-equal in LaunchdBackstop.swift and install.sh) does the same with lockf -t 5 and reads the last byte under the lock. RecoveryLockHandle.replaceContents ends the attempt when pwrite(2) writes no byte, instead of trying again forever. Fixtures: ScriptFixture reports output it cannot read as a failure instead of an empty string. PatchedBackstop saves the run's stdout and stderr, throws on a launch, wait or signal failure, and can hold the log at every record check or shorten the log lock timeout. TestACL.removeAll runs no chmod when there is no ACL. The LaunchAgent refusal fixture checks that its program still calls codesign once before it runs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, and a lock file read is tried three times Independent review of a41341c (round 32), R32-2, R32-3 and the uncertain lock-file end. R32-2, journal parity. record_text_problems (the same block in backstop.sh and uninstall.sh) follows the text as swift-foundation 6.2 reads it instead of refusing what plutil reads otherwise: - A key written twice counts by its first copy, and an escaped key after its escapes (a Kelvin sign as K), as the app's decoder reads them. - A number where the app reads a Float or a Double is refused only when it rounds to infinity, or to 0 from a nonzero value, with the bounds compared as exact decimal digits (json_range), so 7.007e-46 and 3.40282356e38 read as the app reads them. A 0 must be one Foundation's isTrueZero takes. - An Int32 or Int64 is a whole number the type holds as the app reads it (json_whole: 5105.0, 1e3, 1e-400 as 0); an Int64 on which Foundation stops the app (its Decimal precondition) is not known and refused. - UTF-16 and UTF-32 go through iconv(1) as the app's decoder reads them. - Where plutil would read the file otherwise ("view: " lines), the scripts read and edit a view of the journal as the app reads it (check_journal, journal_view): the keys the app reads, the first copy of each, whole numbers as digits, nothing the app skips. A journal published from it drops what the app's own save drops. journal_candidate_ok refuses an edited copy plutil wrote through a Double that the app would read otherwise; the run keeps the old journal and exits 1. - Still refused, as forms the app loads but never writes: a NUL byte and \u0000 in a string the app reads (27 of 3,699 app-loadable journals in this round's corpora), and text not read within 30 s. The 60-row gate from probe32 gives 45 journals accepted as the app loads them and 15 refused, with no app-loadable refusal. R32-3, config parity. config_cutoffs reads config.json's text with the same reader in its config form, without plutil, up to 8 MiB as the binary reads. The 63-row gate gives 42 configs read as the app reads them, 21 rejected and none unavailable; the duplicate floor 0, 1e-400 and unknown 01 rows give cutoffs 0 false. Neither 10/on nor 95/on is chosen for any case the reader can read. Uncertain end. Store.readLockFile and backstop.sh's read_lock_record read the lock file three times, 0.1 s apart (lockReadAttempts, LOCK_READ_ATTEMPTS), before it counts as unreadable, so a read error that passes ends nothing. When insomnia.log holds the session's record, both name the log instead of the unreadable lock file. A file whose reads keep failing still counts as the end; that residual is open. Fixtures: ScriptFixture gives every run its own TMPDIR, so uninstall.sh no longer makes its scratch folder in the shared /tmp. PatchedBackstop.failLockReadBack(times:) fails only the first reads and lockReads() counts them. backstop.sh's log() passes ShellCheck again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…log lock as they are, with their limits Round 33, item 7 of the brief from independent32. - spec section 6: the journal check reads the text as the app does (first copy of a key, whole numbers, Float and Double ranges) and edits a view of the journal where plutil would read it otherwise; it lists what it still refuses (a NUL byte, \u0000 in a string the app reads, text not read within 30 s, an Int64 on which Foundation stops the app, a UTF-32LE BOM) and that an edited copy plutil rewrote through a Double is refused. The config fallback reads up to 8 MiB without plutil; a hand edit reaches the backstop on that path only in a form this reader can read. - spec section 8, README and SECURITY.md: the lock file is read three times, 0.1 s apart. An unreadable lock file keeps to the safe side; it does not show that anyone ended the session, and whether it should end, keep or defer the session is an open decision. The log is named when it holds the record. Insomnia's writers of insomnia.log hold flock(2) (four opens in the app, three in the agent), wait at most 2 s and 5 s, and an appender that does not lock can still break an accepted record. - .greptile/rules.md: the same rules, and the battery and thermal ends run in the backstop as well as in the app; only the Low Power Mode requests need the app alive. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p reads it b930a8a made the scripts read a key written twice by its first copy, as the app's decoder does, but two RecoveryScriptTests cases still expected the old behavior, and the focused runs, chosen by name, missed them. The safe catalog run on 5182db6 failed both. The three duplicate-key journals move from the test of journals the app does not load to the test of journals it loads. In each of the four modes the session ends, the journal published holds the key once with its first copy's value, and the app's decoder is checked to read that copy. The pid row keeps pid 5, which has no identity, so that run exits 1 with the journal kept for it. The test of journals the app does not load now checks that the app's decoder refuses each of its journals. The 5100.5 case expects the line the scripts now write. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… fixture records that cannot be read fail the test Item 6 of round 33. Four slow tables whose rows are independent now run each row on a fixture or home of its own, at most eight at a time, through the existing ScriptFixture.runAll and SeparateRun.runAll. Those wait for and reap every run they started, also when another fails to start. Every row, journal, mode, binary and script axis and every assertion stays. Rows that hold the alive lock take it before the runs start and let it go after they end, as before. - RecoveryScriptTests: both scripts' journal checks in the acceptance table, in four parts each over folders of their own, and the unexpected app binary answers (17 rows, one concurrentRow fixture each). - CutoffAgreementTests: a hung session's recorded cutoffs (10 rows, then the app's own end on this test's home, which runs alone) and the agent's reading of the record (13 rows and the 3 duplicate-record rows). - ScriptFixture's calls, chmod calls, slow polls and log, and PatchedBackstop's calls, output and sudo-time snapshots, read a missing file as none but fail the test on a file that is there and cannot be read, so an unread record never passes for no calls. SeparateRun's log throws on such a file. The test of journals the app does not load reads the lock file the same way. Three more tables were tried overlapped and stay serial: the journals the app does not load, the journals it loads, and the numeric end floors. In two back-to-back pairs on the same 27 tests (abB-summary.txt) each was slower overlapped in both runs; the four above were faster in both. Only script runs overlap, each on files of its own. The app-side steps (the Harness, its INSOMNIA_HOME and SessionManager calls on the main actor) stay serial, before or after the runs. No product file, deadline, limit or workflow changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the binary fails Round 33 recovery, from the audit of every assertion round 33 removed or replaced. Before round 33 the test of configs read neither way ran the config cut short and the one with an escape JSON does not have with each way the binary fails, at two batteries, on the record. b930a8a's reader reads both as rejected, so they moved to the rejected-config test, where 0b42d74 ran them only with the binary missing. That dropped 12 combinations: a binary that declares another version, one that prints something else and one that does not answer, each at 50% at critical heat and at 39%. The 12 run again, on the record, with the rejected-config test's own expectations: the session ends at 39% and is kept at 50%, and the log says the file was read here, the app rejects it, and the record's 40% end floor with thermal rules off is enforced. No product file changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| if (( i >= 9007199254740992 )) || { (( i == 9007199254740991 )) && [[ "${f:0:1}" > 4 ]]; }; then | ||
| # The Double is 2^53 or more: an Int64 only, read as a Decimal. | ||
| [[ "$2" == int64 ]] || return 0 | ||
| if [[ -z "$neg" ]] && (( i > 9223372036854775295 )); then return 0; fi | ||
| json_decimal_reads || reads=$? | ||
| (( reads != 2 )) || whole_trap=1 | ||
| (( reads == 0 )) || return 0 | ||
| whole_value="$neg$i" |
There was a problem hiding this comment.
Rejected fraction disables battery cutoff
The new json_whole accepts -9223372036854775807.5, which the app rejects. This branch skips the upper check for negative values and returns the whole part after json_decimal_reads.
If a hand edit puts this value in endFloor while the app is hung and the installed binary cannot answer, config_cutoffs clamps it to 0 instead of using the session’s recorded floor. A session recorded with a 30% floor then stays awake at 20%.
Check the negative fractional boundary before accepting the number, in both this helper and its copy in scripts/uninstall.sh.
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/backstop.sh
Line: 877-884
Comment:
**Rejected fraction disables battery cutoff**
The new `json_whole` accepts `-9223372036854775807.5`, which the app rejects. This branch skips the upper check for negative values and returns the whole part after `json_decimal_reads`.
If a hand edit puts this value in `endFloor` while the app is hung and the installed binary cannot answer, `config_cutoffs` clamps it to 0 instead of using the session’s recorded floor. A session recorded with a 30% floor then stays awake at 20%.
Check the negative fractional boundary before accepting the number, in both this helper and its copy in `scripts/uninstall.sh`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| decoder, or with its own reader when that binary cannot answer, so the file | ||
| decides those two for both. Its own reader takes a hand edit only in a form it | ||
| can read as the app does: it does not use a file over 8 MiB, one it cannot | ||
| finish reading within 30 s, or one on which the app's decoder stops, and then |
There was a problem hiding this comment.
Recovery instructions disagree
This updated fallback description conflicts with “How recovery works” earlier in README.md:
- The earlier section still says the reader stops at 64 KiB, requires
plutil, and refuses duplicate or rounded values. The new reader takes up to 8 MiB withoutplutiland accepts those forms. - The hardware checklist in
docs/release-validation.mdstill expects duplicate journal keys to stop recovery.
Update those descriptions together. Otherwise users get conflicting advice, and testers following the checklist would mark the new behavior as a failure.
Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 687-690
Comment:
**Recovery instructions disagree**
This updated fallback description conflicts with “How recovery works” earlier in `README.md`:
- The earlier section still says the reader stops at 64 KiB, requires `plutil`, and refuses duplicate or rounded values. The new reader takes up to 8 MiB without `plutil` and accepts those forms.
- The hardware checklist in `docs/release-validation.md` still expects duplicate journal keys to stop recovery.
Update those descriptions together. Otherwise users get conflicting advice, and testers following the checklist would mark the new behavior as a failure.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Why
Sleep stayed disabled until the journaled deadline whenever the app stopped running. The launchd agent restored the journal only once
endsAthad passed, and the battery and thermal floors lived in the app alone. A crash, force-quit, or hang under a closed lid left the Mac awake with no cutoff but the deadline, and the default maximum was 30 days. This PR gives the shell a way to test that the app is alive, moves the two ends (end floor, critical heat) into the backstop as well, and lowers the default ceiling to 24 hours.What
This section describes the branch as of 62d57c5. The history of each round follows further down.
Alive lock and launch gate. The app holds an exclusive
flock(2)onAPP_SUPPORT/.app.alivefrom launch until exit (AppAliveLock, O_CLOEXEC, created 0600, never unlinked). The kernel releases the lock however the process dies.LaunchGatetakes the lock before anything else in a launch runs. Only the copy that holds it registers the CoreAudio device callback, runs the login item check, opens the status item and reconciles. A copy that cannot take the lock within 2 seconds posts "Insomnia is already running" and quits without changing the owner's journal, session or audio.Backstop checks.
backstop.shruns three checks before it lets a valid session stand, in this order. Each one that fails ends the session as--forcewould and logs the reason.lockf -k -s -t 0 .app.alive /usr/bin/true. Exit 75 means a process holds the lock. Exit 0 means nobody does, and the session ends with "Insomnia is not running". Any other exit is logged and also counts as not running.pmset -g batt. An internal battery present, 'Battery Power' as the source, and a percentage belowendFloor(default 10, strict, so 0 disables) ends the session. A present battery whose source or percentage cannot be read, or a failing pmset, ends too. No InternalBattery line means no battery rule only whenioregfinds no AppleSmartBattery service, which is the checkPowerMonitor.classifymakes.notifyutil -g com.apple.system.thermalpressurelevel. Level 3 (trapping) or above ends the session whenthermalRulesis true (the default). An unreadable level only warns.A run with all three passing executes the two reads, and the cutoff read under "One set of cutoffs" when config.json exists, and logs nothing.
--forceruns none of the probes. Each read is the shell's own background job with fd 9 closed, so a hung read never holds the recovery lock. It has the undo commands' time limit on theSECONDSclock, then gets SIGTERM and SIGKILL by jobspec. Undo commands keep #50'ssupervise_commandandrun_boundedunchanged.Ending a valid session. A run checks that state.json loads as the app loads it before it ends a valid session,
--forceincluded. A journal that fails stops the run with session.json, the journal and every undo entry kept (exit 1). A run that ends a valid session removes session.json under the lock before it undoes anything, so a relaunched app finds no session to resume. When the file cannot be removed (an immutable file, for example), the run records the end inended-session.json, a copy of its bytes. When that file cannot be written either, it records the end in the journal asendedSession, the same bytes in base64. When state.json cannot be written either, it writes the copy to a new file namedended-session.json.followed by eight letters or digits (mktemp, mode 0600), beside them or, when that folder takes no new file, in~/Library/Logs/Insomnia, and keeps it only when it reads back identical. When neither folder takes a new file, it writes the record into the recovery lock file,.recovery.lock, which already exists:ended-session-v1, a space, the same base64 and a newline, written in place so the file keeps its inode and stays the lock. The log folder counts only while it is a directory owned by this user and not a symlink, a record aside only when it is a regular file owned by this user, and the lock file only while it is a regular file this user owns, not a symlink, with the inode of the lock the writer holds. ended-session.json counts as session.json does: a symlink there is followed to a regular file, and its owner is not checked. Each record is written and read back before the undo. While one matches session.json, the app's reconcile restores instead of resuming, the app's tick and next transaction end a session it still holds, and every later run ends it again without the checks. A record of other bytes ends nothing. The record of session.json's bytes cut short as a writer leaves it when it stops partway (the record's first bytes, or the whole record followed by bytes the file held before) counts as that session's end, and no writer empties it. The app keeps the bytes the file shares with the start of the record, cuts the file to them and appends the rest. The agent appends the rest to the record's first bytes, leaves the whole record with bytes after it as it is and goes on to the log, and otherwise writes with>, which empties the file first. A writer stopped partway therefore leaves the old bytes, an empty file or the record's first bytes, never the record's first bytes over old bytes that differ. Neither writer writes over a lock file it cannot read; the app then goes on to the log, as the agent does. Other content in the lock file that is read whole but is no record (other bytes, a record of other bytes cut short past the first byte where the two differ, more than 1 MiB) ends nothing, and the agent empties it once session.json is gone, or while session.json is a regular file it can read. A relaunch that resumes a session empties such content first, and logs it when it cannot. A start settles the lock file under the recovery lock before it writes its session.json (Store.settleLockForStart): it keeps or completes a record of the session.json it replaces, empties anything else, and is refused when the file cannot be settled, or cannot be read while an earlier session.json is there. A lock file that cannot be read in three tries 0.1 s apart counts as the end of whatever session.json holds, since it may hold that record, until it can be read or session.json is gone. ended-session.json and a record aside are removed only when session.json is gone orcmpreports other bytes; whilecmpcannot compare them they stay. When the lock file takes no write either, the run appends the end to~/Library/Logs/Insomnia/insomnia.logas one line (LogEndRecord):insomnia-ended-session-v1, the size of session.json and its bytes in base64. It writes the line under the recovery lock, in onewrite(2), to a regular file this user owns and not a symlink whose descriptor and path have the same device and inode, and counts it only once it reads it back as a whole line. Every writer of insomnia.log that Insomnia ships (the app, the agent and the LaunchAgent's refusal line) takesflock(2)on the log file after the recovery lock and holds it from its read of the file's last byte to the end of its write. When that byte is not a newline or cannot be read, the writer puts a newline before its own line. A record at the end of the file that lacks only its newline therefore stays a line of its own and counts. The app waits up to 2 s for the log lock and the agent 5 s. A record not locked in time is not written and does not count. A process that appends without the lock can still break a record. Every reader looks for exactly that line in insomnia.log and insomnia.log.1, and a match counts as the other records do. The app rotates insomnia.log only while it holds the recovery lock, and copies a record of the session.json still on disk into the file it renames. The backstop never rotates it. A session.json over 64 KiB is never recorded there, and a log that cannot be read or is over 64 MiB holds no record. A record matches bytes, not a session, so a session.json written later with the same bytes reads as ended too. The app's own end uses the same places in the same order when it cannot remove session.json. Only when neither folder takes a new file, the lock file takes no write and the log takes no line either (a full disk or an I/O error, say), or when the run stops before any record of it counts, is the end unrecorded. The run then still restores sleep, keepssleepDisabledByUs, and exits 1 every minute. The app writes the journal before every resume, so it resumes nothing while state.json cannot be written. Before it resumes a session whose journal says Insomnia disabled sleep, it also readspmset -g, and aSleepDisabledof 0 ends that session. It then replaces session.json with the same bytes before it holds sleep, and a file it cannot replace ends the session too. A 1 with a session.json that can be replaced again resumes, so that case is listed under "Not covered".One set of cutoffs. While a session is valid and the app holds the alive lock, it passes the file's bytes on standard input to the installed binary's one-shot mode,
Insomnia --agent-cutoffs 33(AgentCutoffsCommand). That mode decodes them withStore.decodeConfig, the decoderStore.loadConfiguses, prints the end floor clamped asnormalizeFloorsclamps it and the thermal rule, and exits before AppKit starts. It runs as a bounded read with fd 9 closed, and only when the bundle's Info.plist declaresInsomniaAgentCutoffsVersion3. A missing, non-regular or unreadable file, or one the decoder rejects, gives the cutoffs the journal records for the session,sessionCutoffsin state.json, which the same binary reads with the app's own reader (Insomnia --agent-session-cutoffs 33) after it decodes the whole journal asStore.loadStatedoes. A journal that records none (a session an older build started) or no state.json gives the app's defaults (10%, on). The agent checks the parts of the journal the app decodes before it reads it (check_journal). It reads the text as the app's decoder reads it: a key written twice counts by its first copy, a number by the value the app's type takes, and UTF-16 and UTF-32 go through iconv (UTF-32LE with a byte order mark is refused, as the app refuses it). Where plutil would read the file otherwise, the agent and uninstall.sh read and edit a view of the journal as the app reads it. It still refuses some text the app may load but never writes: a NUL byte, an escaped NUL character in a string the app reads, text not read within 30 s, and an Int64 on which Foundation stops the app. A journal that fails, a state.json that is not a readable regular file among them, or one the binary answersrejectedfor stops the run with the session kept. When the binary is missing, declares another version, does not answer within 30 s or prints anything else for config.json, the agent reads the file itself (config_cutoffs): one bounded copy of at most 8 MiB, the binary's limit, read byrecord_text_problemsin its config form without plutil, which also checks the type of every value the app'sConfigdecoder reads. It then takesendFloor(10 when absent or null, clamped to 0 to 95) andthermalRules(true when absent or null) and logs that it did. A value of a type the decoder does not take counts as a rejected file. When the binary cannot answer for the journal, the agent readssessionCutoffsfrom the checked journal itself (journal_cutoffs, which takes only the text the app writes) and logs that it did. A journal value the app does not write, a state.json that is a symlink to nothing, no record or no state.json gives the app's defaults (10%, on) while config.json is missing or rejected. Only while config.json is there and neither the binary nor the agent can read it (more than 8 MiB, an Int64 on which Foundation stops the app, or not read within 30 s) do those cases give the strictest values (95%, thermal rules on), with a log line naming the cause. The app writessessionCutoffsbefore a session starts or resumes and before a change to either cutoff takes effect. A session whose cutoffs cannot be recorded ends, and a change that cannot be recorded is refused. The file therefore decides both cutoffs for the app and the agent, except where the agent uses the defaults or the strictest values above while the app keeps enforcing its own settings. The agent can then end a session the app would keep, or, with the defaults, keep one below the end floor the app enforces. The app checks config.json in every transaction (start, extend, end, reconcile) and on the 1 Hz tick while a session runs with the lid open:normalizeFloorsdoes. No other setting changes.config.json.unreadable-<time>and replaced with the settings in use. While it cannot be renamed, no session runs.Settings saves a change to either cutoff before it applies it. During a session it first records the change in the journal, under the recovery lock taken without waiting. A busy lock or a journal write that fails changes neither side, and Settings says why. A config.json save that fails puts the old record back, so neither side changes. When the record cannot be put back either, the app ends the session on disk before the lock is released (it removes session.json or records the end in one of the places above), Settings says so, and the undo runs in the next transaction. When no place takes that record, the session stays on disk with the journal's new cutoffs, and only the pending end in the app's memory stands for it until that end runs. Other settings still apply at once.
Session length.
Config.maxDurationdefaults to 24 hours, in the decoder too. Settings saves the whole struct, so ordinary config.json files from older builds hold 30 days and the 3-day preset as explicit values; the decoder reads exactly those legacy defaults as the current ones and keeps any other value a person set. The 3-day preset is gone from the defaults. A typed time or default preset that would end past the maximum is refused beside the pills with the allowance ("Up to 1d", or "At the maximum" when an extension has nothing left) instead of being clamped quietly; the typed value stays for editing. The Days tooltip reads "Up to 1d per session" from the configured maximum. Settings gains one caption under "Maximum session" saying how to change it.Round 33: review of a41341c
An independent review of a41341c (round 32) returned NEEDS CHANGES: R32-1 (P1, the same defect as Greptile's 4227512547), R32-2 and R32-3 (P2), a lock file that cannot be read, the failed hosted run with its fixture errors, and the docs. Round 33 adds six commits on a41341c: 1e4e1d4 (the log lock), b930a8a (the journal and config readers, lock file reads), 5182db6 (docs), 1598bd4 and 0b42d74 (tests), and 62d57c5 (tests, from an audit of every assertion the round removed or replaced). The round's first owner stopped on repeated API errors before its final checks, push and body edit. A recovery owner audited the round, ran the final checks on the final head and pushed it. Main is still b5f7cf0, so this round has no merge.
Hosted CI on a41341c failed. In run 37895264686 (job 113705065958) the watchdog stopped
swift testafter 1151 cases had started and 1150 had passed, with no failed case, assertion or skip. The case it stopped,RecoveryScriptTests/testUninstallStopsAHungCallOnTimeWhenEveryPollIsSlow, had run 3.9 s. The release and lid steps were skipped. Round 31's line "Hosted CI on f2298fe passed" stays as written; it was about f2298fe.flock(2)on the log file itself, after the recovery lock, and holds it from its read of the last byte to the end of its write. After locking, it checks that its descriptor is still on the file the path names and opens the path again when it is not (four opens at most in the app, three in the agent). The app (OwnerOnly.appendToLog,LogEndRecord.append) waits up to 2 s. An ordinary line it cannot lock in time waits in memory (64 KiB at most, oldest whole lines dropped first) and goes out before the next line that gets the lock. A record it cannot lock in time is not written and does not count. The app's rotation renames the file under the same lock. The agent'slogandrecord_end_in_logtake the lock with/usr/bin/lockf -s -t 5on the log's descriptor. A line not locked in time goes to standard error, saying so, and a record is not written. The LaunchAgent's refusal line (AGENT_PROGRAM, byte for byte equal in LaunchdBackstop.swift and install.sh) does the same withlockf -s -t 5, and reads the last byte, writes its newline and writes its line under that one lock.RecoveryLockHandle.replaceContentsandOwnerOnly.writeAllnow end the attempt when a write returns no byte, where they could otherwise retry forever.record_text_problems(the same block in backstop.sh and uninstall.sh) now reads the text as the app's decoder reads it, instead of refusing what plutil would read otherwise. A key written twice counts by its first copy, and an escaped key after its escapes (a Kelvin sign as K). A Float or Double field is refused only when the number rounds to infinity, or to 0 from a nonzero value, with the bounds compared as exact decimal digits. An Int32 or Int64 field takes any whole number the type holds as the app reads it (5105.0,1e3,1e-400as 0). UTF-16 and UTF-32 go through iconv. Where plutil would read the file otherwise, the scripts read and edit a view of the journal as the app reads it, and a journal they publish drops what the app's own save drops (keys it does not read, later copies of a key). An edited copy that plutil wrote through aDoublethe app would read otherwise is refused, and the run keeps the old journal and exits 1. Still refused, as forms the app loads but never writes: a NUL byte, an escaped NUL character in a string the app reads, text not read within 30 s, and an Int64 on which Foundation stops the app.config_cutoffsnow reads config.json with the same reader in its config form, without plutil, from one bounded copy of at most 8 MiB, the binary's limit. A file the decoder rejects counts as rejected, as the binary answers for it, so it gives the record, else the defaults (10%, on). On the review's 63 configs the reader gives the app's cutoffs for the 42 the app reads and rejects the other 21. With no record, 95% with thermal rules on remains only while config.json is over 8 MiB, holds an Int64 on which Foundation stops the app, or is not read within 30 s.Store.readLockFileand backstop.sh'sread_lock_recordtry three times, 0.1 s apart, before the file counts as unreadable, so a read error that passes ends nothing. When insomnia.log holds a whole record of the session, both name the log instead. A lock file whose reads keep failing still counts as the end of whatever session.json holds. That keeps to the safe side, but it does not show that anyone ended the session, and whether to end, keep or defer such a session is open.ScriptFixture.runAllandSeparateRun.runAll, which wait for and reap every run they start. The app-side steps stay serial. Three more tables were tried that way, were slower in both of two back-to-back pairs, and stay serial. ScriptFixture and PatchedBackstop now fail the test on an output or record file that is there and cannot be read, instead of reading it as empty. PatchedBackstop throws on a launch, wait or signal failure. ScriptFixture gives every run its own TMPDIR, so uninstall.sh no longer makes its scratch folder in the shared /tmp.Greptile on a41341c, in round 33
Review comment 4227512547 (P1, "Log messages erase end records") is an open thread. This round posted no reply and resolved nothing. 1e4e1d4 takes the first route the comment names, a lock shared by every log writer. The other route, always putting a newline first, was not used: the round 32 review showed that it does not hold when a write is cut short and then continued. A process that appends without the lock can still break an accepted record.
Tests for round 33
All tests use fake files and fake commands. None runs the real app, agent, pmset or sudo.
testAnAppendWaitsForTheLogsLockAndStartsAfterWhatItsHolderLeft,testALineThatGetsNoLockIsWrittenBeforeTheNextOneandtestAWriteThatWritesNothingEndsTheAppend.testTheAgentsWritesWaitForAWriterThatHoldsTheLogandtestTheAgentWritesNothingToALogItCannotLockAndSaysSo.testTheAgentsLineWaitsForEveryPieceOfAnAppLineCutShortandtestTheAgentsLineFollowsARotationItWaitedFor. They run the LaunchAgent's program with its one codesign call replaced by /usr/bin/false, and the fixture runs nothing if the program would still call codesign.testAWriteInPlaceThatWritesNothingEndsTheAttempt,testTheAppReadsALockFileAgainBeforeItCountsAsTheEndandtestTheAgentReadsALockFileAgainBeforeItCountsAsTheEnd.testDuplicatedKeptDisplayRecordsAreReadAsTheAppReadsThemreplacestestDuplicatedKeptDisplayRecordsAreRefusedByBothScripts, whose name stated the old rule. The acceptance and reader tables have new rows.9007199254740993.0,1.0000000000000001and1e-99999. 53 more rows changed only their problem wording or view lines. All 88 rows keep their labels and app flags.testAJournalTheAppLoadsLetsTheRunEndAValidSession. In each of four modes the run ends the session and publishes the first copy, which the app's decoder is checked to read. The pid row keeps pid 5, which has no identity, so that run exits 1 with the journal kept. The test of journals the app does not load now checks that the app's decoder refuses each of its journals.Round 33 verification
Round 33's restrictions forbid any actual ACL change and any Security, Keychain, launchd, install or uninstall action, even on temporary files. A text screen (
safe_select.py) of the 1384 cases outside the three always-skipped classes flags 230 that name such an action. The mandated full command (/usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests) was therefore not run. Six flagged cases were reviewed and run anyway: three that only name install.sh or uninstall.sh while they run functions extracted from them in a fixture, and three LaunchAgent cases that replace codesign with /usr/bin/false.testTheAgentWritesNothingToALogItCannotLockAndSaysSo, on its own expectation before 1e4e1d4 was committed. safe-catalog-1 on 5182db6 failed two cases b930a8a had made stale, which 1598bd4 corrects. fixA-1 failed 4 assertion lines in one case before 1598bd4 was committed. final-focused-1 never started, because another run held the shared lock, and counts as no run. All other runs passed.bash -nunder /bin/bash 3.2.57 on the 7 scripts and the bash 4 pattern grep passed on 0b42d74.swift build -c release -Xswiftc -warnings-as-errorsexited 0 with no warnings, built from nothing in a private scratch path, and scripts/check-lid-simulation-gate.sh exited 0. 62d57c5 changes one test file, so these checks of the product and scripts hold for it unchanged.Choices for the parent
Round 31: review of f2298fe
An independent review of f2298fe (round 30) returned NEEDS CHANGES with six items, R30-1 to R30-6. 159a570 answers R30-1 to R30-5 in code and tests. a41341c corrects two test tables that full run 1 failed on their own expectations, adds a journal row, and makes the docs' R30-6 wording more exact. R30-6 is a choice for the parent, and no code changed for it. Main is still b5f7cf0, so this round has no merge.
Hosted CI on f2298fe passed. Run 37865392744 (job 113610779091) started and ended 1427 cases: 1417 passed, 10 UIStatusTests cases were skipped and none failed. The release build and the lid step passed too. Round 29's item 5, the "Hosted CI split proposal" and two "Not covered" lines said a single job would likely be stopped again. That run did not bear them out. Those lines stay as they were written, and this paragraph corrects them. The split is still only a proposal, and the workflow is main's.
RecoveryLockHandle.replaceContentsnow reads the bytes the file shares with the start of the record (preadon the held descriptor), cuts the file to them, appends the rest and syncs. An app stopped partway therefore leaves the old bytes, an empty file or the record's first bytes. It never leaves the record's first bytes over old bytes that differ. The record's first bytes count as that session's end, so the end counts from the first byte the writer changes. The agent'srecord_end_in_lockalready never left that state: it appends with>>only to the record's first bytes, and otherwise writes with>, which empties the file first. Neither writer writes over a lock file it cannot read.Store.recordSessionEndInLocknow returns false there, and the app goes on to the log, as the agent does. A relaunch that resumes a session first empties the lock file, whose content ends nothing at that point. When it cannot, it logs that and resumes, and a later end written there still keeps only the shared bytes.Store.settleLockForStartruns under the recovery lock before a start writes its session.json. It keeps a whole record of the session.json the start replaces (alone or with bytes after it), completes that record's first bytes to the whole record, and empties anything else. A stale record's first bytes ("e", say) are also the first bytes of the new session's record, so they no longer reach the new session. A lock file the start cannot settle refuses the start. A lock file it cannot read refuses a start over an earlier session.json, as before. With no session.json such a file ends nothing, and the start empties it. The start clears the journal'sendedSessionin the same write that records the new cutoffs, after its session.json is written. It empties the replaced file's record at its last step, just before it disables sleep. A failed start puts back the journal, session.json and the lock file's exact bytes. Bytes that count as the earlier session's end go back before its session.json, and other bytes after it. Lock content over 1 MiB, and an unreadable lock file emptied with no earlier session.json, are not put back; neither ended a session.OwnerOnly.appendToLog(every app line) andLogEndRecord.appendin the app, andlogandrecord_end_in_login the agent, do so in the same write. The LaunchAgent's own refusal line reads the last byte withtail -c 1and appends the newline in a write of its own just before the line. That line is inLaunchdBackstop.agentProgramand in install.sh'sAGENT_PROGRAM, which must stay byte for byte equal. A record at the end of the file then keeps its line, and a record written after a line cut short starts its own line and reads back on the first attempt. In a log this user may only write to, the last byte cannot be read, so the newline always goes first. install.sh'sAGENT_PROGRAMline now differs from main's.startedAtMicrosonly after astartedAtthat is there and not null, and atbootSessiononly after both, asFrozenProcessdecodes them. The backstop keeps such an entry and signals nothing for it. A whole number written with a fraction or an exponent passes when the field's type holds it and aDoubleholds it exactly, so1e18passes for an Int64. UTF-32 without a byte order mark, and UTF-32BE with one, are read through iconv. AsessionCutoffswritten twice, with an escape JSON does not have, or as1.no longer stops the run. The journal loads, and the agent's own reader takes the value as a record the app does not write. For a record written twice the binary reads the first copy, as the app does. The review's six rows, the rounded and underflow numbers, keys the app ignores, UTF-16 and UTF-32 are rows in the agreement table for the app, the binary and both scripts. Still refused, and never written by the app: a key the app reads twice in an object it reads (other thansessionCutoffs), a whole number written with a fraction or an exponent that aDoubledoes not hold exactly (9007199254740993.0), a number aDoublerounds (1.0000000000000001,1e-99999), a number or escape plutil cannot parse even where the app skips it (01,1e400,\a, an escaped NUL character, a lone surrogate), a NUL byte, and UTF-32LE with a byte order mark, which the app refuses too. The check still runs before anything is written.config_cutoffs). It takes one bounded copy of at most 64 KiB. plutil must parse the copy, andrecord_text_problemsmust pass it in its config form, which follows the whole text as the journal check does and also checks the type of every value the app'sConfigdecoder reads. A value of the wrong type counts as a rejected file. A key the app reads written twice, an escape JSON does not have, a number the app rounds or cannot hold, or text the reader cannot follow means the file is not used. Otherwise the agent takesendFloor(10 when absent or null, clamped to 0 to 95) andthermalRules(true when absent or null) and logs that it read them itself. AsessionCutoffsthe app does not write and a state.json that is a symlink to nothing now count as no record, as the app reads them, and the binary'sforeignanswer gives the defaults too. With no record, the defaults (10%, thermal rules on) apply while config.json is missing or rejected. 95% with thermal rules on applies only while config.json is there but neither the binary nor the agent can read it. Both are stopgaps the parent has not approved (spec section 6); "Choices for the parent" below lists them. A hand edit to config.json that the agent's own reader can use now reaches the agent with or without the binary. One it cannot use (a key the app reads written twice, a number the app rounds, more than 64 KiB, say) reaches it only through the binary.Greptile on 277b62a, in round 31
Greptile's P1 on 277b62a ("Partial end record permits resumption", review comment 4224841021) is still an open thread. This round posted no reply and resolved nothing. f2298fe made a record cut short count as its session's end. The round 30 review then found that the app's writer could still leave the record's first bytes over old bytes that differ, which no reader counts (R30-1). Round 31's writer keeps only the bytes the file shares with the record, so that state no longer arises, and a start settles the lock file before it writes (R30-3). An end stopped before any record of it counts still records nothing (R30-6).
Tests for round 31
All tests use fake files and fake commands. None runs the real app, agent, pmset or sudo.
testAWriterStoppedPartwayNeverLeavesLessOfThisEndThanItFoundstops the app's real writer with a file size limit at every cut position over six old contents. It checks that each state is the old bytes, an empty file reached only from content that did not count, or the record's first bytes, and that none counts for less than the content found. It writes and reads the agent's states too.testAnEndStoppedPartwayAfterAResumeEndsTheSessionFromItsFirstByte: after a resume over old lock bytes, an end stopped after k bytes ends the session for a relaunch and for the agent from k = 1, without holding sleep again. k = 0 records nothing and resumes (R30-6). For k = 1 with a cleanup that cannot empty the file, a later start empties it first and its session runs and resumes normally.testAStartLeavesNothingInTheLockFileThatEndsItsOwnSession: stale content ("e", another session's record, other text) is gone before the start writes session.json, so a crash at each later step resumes. A start over an earlier session.json keeps or completes that session's record until its last step. A lock file the start cannot settle (append-only once the start has read it) rolls the start back; once repaired, the start goes through.testAFailedStartPutsBackSessionJournalAndLockFileExactly: failures when the agent cannot be armed and at the last step put back session.json, state.json and the lock file's exact bytes for each content, with SleepDisabled untouched.testALockFileThatCannotBeReadIsNeverWrittenOverOrReplacedWithASession: a read error after open (Store.lockReadErrnoForTesting, DEBUG only) refuses a start over an earlier session.json with nothing changed. With no session.json the start empties the file. An end that reaches the lock file records itself in insomnia.log and leaves the file as it was.testEveryAppWriterStartsOnALineOfItsOwnAfterALineCutShort,testTwoRotationsKeepARecordWithoutItsNewlineAndALineCutShortApart,testTheTickAdoptsARecordWithoutItsNewlineAndKeepsIt,testTheAgentsLinesLeaveARecordWithoutItsNewlineWholeandtestTheAgentsRecordAfterALineCutShortReadsBackOnTheFirstAttempt. They cover the app's lines, a record whose newline alone is missing, a line an app write left partway (a file size limit), a log this user may only write to, two rotations, adoption by the tick and by the agent, a read-back that fails and its retry, and a record of other bytes before and after.testALineCutShortIsEndedBeforeTheNextLineand LaunchdBackstopTeststestAgentProgramsRefusalLineNeverJoinsALineCutShort(the LaunchAgent's program run as launchd runs it, with codesign failing).testUninstallsBackstopRunKeepsALineCutShortApartInBothModes(uninstall.sh with and without--force, the first attempt included),testProvisionalEntriesTheAppDoesNotReadFurtherDoNotBlockRecovery, and new rows in the agreement and reader tables (the six review rows,1e18, 2^62, Int32 and Int64 limits, UTF-32, a record twice, with a bad escape, as1.and as+1).testTheAgentReadsConfigItselfWhenTheAppBinaryCannotAnswerreplacestestTheAgentEnforcesTheStrictestCutoffsWhenTheAppBinaryCannotAnswer, whose name stated the old rule. Three tables run each policy with each way the binary fails:testTheAgentEnforcesEachPolicyInConfigAsTheAppsBinaryDoes,testARejectedConfigLeavesTheRecordOrTheDefaultsWithOrWithoutTheBinaryandtestAConfigReadNeitherWayLeavesTheRecordOrTheStrictest.Round 31 verification
All runs used fakes only. Each discovery and each run passed
--skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests.swift test list --skip-buildexits 0 with those flags but still lists the 69 IDs of the three classes, so the scripts removed them by name and refused an empty selection or one outside the named classes. Each check after a run compared the started and ended names with the selection or the expected set; none of the three classes started and no name started twice.Focused runs ran
swift test --skip-builddirectly, without the shared test lock, on drafts over f2298fe before each commit:appendToLogstarted ending a line cut short. Rewritten for the new rules.GREPhad to write the cut line first.Full runs used the shared lock (
/usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests) with swift as lockf's direct child, on the clean committed tree, with the lock's inode 201732085 the same before, while held and after:pmset -g logshows no sleep in that window.pmset -g logshows one 5 s idle sleep at 06:41:51Z.bash -nunder /bin/bash 3.2.57 passed on all 7 scripts.swift build -c release -Xswiftc -warnings-as-errorson a41341c exited 0 with no warnings, built from nothing in a private scratch path (06:45:11Z to 06:45:29Z); in.buildthe same command found everything up to date, since no source file was newer than its release binary. Before that, scripts/check-lid-simulation-gate.sh exited 0 on the clean tree (06:44:33Z to 06:44:34Z): both its builds were up to date, the plain release build holds no watcher symbol, and the lid simulation build holds the watcher.ftruncateand itspwrite, or the agent between>and its write.Choices for the parent
policy-ambiguity.md(the deadline and liveness only, an immediate end, a rule per case) are still open. None is approved.Round 29: review of 47bdc6c
An independent review of 47bdc6c (round 28) returned NEEDS CHANGES with six items. 277b62a answers all six in one commit. f2298fe then changes the lock file record for Greptile's P1 on 277b62a ("Greptile on 277b62a" below). Main is still b5f7cf0, which round 27 merged, so this round has no merge. Hosted CI on 47bdc6c failed: the workflow's 20-minute watchdog stopped run 37810613589 (job 113426181373) with 1114 cases started and 1113 passed, no failed case or assertion, and the release and lid steps skipped (item 5).
LogEndRecord,Store.recordSessionEndInLog) and the agent (record_end_in_log) append one line to~/Library/Logs/Insomnia/insomnia.log:insomnia-ended-session-v1, the size of session.json and its bytes in base64. The writer holds the recovery lock, opens the log only while it is a regular file this user owns and not a symlink (O_APPEND,O_NOFOLLOW,O_NONBLOCK), checks that the descriptor and the path have the same device and inode, writes the line in onewrite(2), and counts it only once it reads it back as a whole line. The app (reconcile, the tick, every transaction), every agent run and uninstall.sh look for exactly the line the current session.json gives, in insomnia.log and insomnia.log.1. The app now rotates insomnia.log only while it holds the recovery lock (OwnerOnly.LogRotation), so no rotation runs between the agent's write and its read-back. Before a rotation drops the old.1, it copies a record of the session.json still on disk into the file it renames. The backstop never rotates.uninstall.sh --purgeremoves the logs only once session.json is gone. What remains, not waived:.1cannot be read, or while session.json cannot be read and.1holds any record.check_journalstill reads every object and array, but now checks only whatStore.decodeStatedecodes: the top level, the arrays underfrozenProcesses,frozenPids,savedAudioOutputsandappNapOverrides, and the objects in them. It now accepts whole numbers written with a fraction or an exponent (5105.0,1e2) up to 2^53, which reach the binary as digits; a key twice, an escape or a number where the app reads nothing; and UTF-16 with or without a byte order mark, which it converts with/usr/bin/iconv. It still refuses these journals, some of which the app loads and none of which it writes: a key the app reads written twice in an object it reads; a whole number past 2^53 written with a fraction or an exponent, or one aDoublerounds to whole (1e18,1.0000000000000001,1e-99999); a number or escape plutil cannot parse even where the app skips it (01,1e400,\a, an escaped NUL character, a lone surrogate);1.undersessionCutoffs; a NUL byte; and UTF-32. With such a journal the agent keeps the session and its sleep hold and undoes nothing until the app or a person rewrites the file, and the first run after that ends the session if it is over. The round 28 probe of a raw tab, newline and U+0001 stays closed, as the review said, and was not repeated.script_dir(): parameter expansion onBASH_SOURCE[0], thenCDPATH='' cd -- … && pwd, with nodirname.testUninstallFindsItsCheckoutWithoutPATHOrCDPATHruns uninstall.sh by a relative path from a checkout, with adirnameand acatfirst in PATH that print a decoy checkout's folder and CDPATH set to that decoy. It runs the checkout's own backstop.sh and calls neither stand-in. Its control, the oldcd "$(dirname …)"line, runs the decoy's.testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwnno longer compares the line word for word: it runs each shipped script's code up toin_checkoutfrom a zip folder whose parent looks like a checkout.PathSubstitutionTestsgainsdirnameandiconvstand-ins. The barecatinbounded()stays on install.sh's word-for-word line; uninstall.sh callsbounded()only with fixed paths, never$SUDO, so thatcatnever runs. install.sh still runs a baredirnamefor its own folder. That line is main's and is outside this PR.sessionCutoffsholds a value the app does not write; state.json is a symlink to nothing. The agent still uses 95% with thermal rules on there, as at 47bdc6c, and can end a session the app keeps. That fallback is not a choice made in this round. The evidence filepolicy-ambiguity.mdlists the alternatives (the app's defaults, the deadline and liveness only, ending at once, a rule per case) and their costs for the parent. The README and spec no longer say the agent enforces what the app does in those cases.sessionCutoffstable, the three AppEncodedJournalScriptTests tables, LockEndRecordTests' every-shape table and the two CutoffAgreementTests tables where the binary cannot answer. Journals the app writes are still built one at a time first, because the Harness changes process-global state. Every row, name and assertion is kept, every row is awaited and reaped, and a throw in any row reaches the test. Rows whose fakes answer at once get the production limits for commands, the lock and uninstall's calls (ScriptFixture.concurrentRow()), because with eight runs at once a fake was seen to start too late for the 1 s limit. The two rows whose binary hangs wait 5 s for it, not 1 s. Locally the changed tests took 155.9 s against 243.4 s in round 27, and about 107 s less at round 27's pace. ThesessionCutoffstable got 4.3 s slower locally; its hosted effect is not measured. The whole catalog still projects to about 1274 to 1280 s of hosted test time against about 1183 s left after the build, so a single job will likely be stopped again. "Hosted CI split proposal" below gives two jobs for the parent to decide on. Nothing in the workflow changed..greptile/rules.mdnow describe the log record and its rotation, the narrower journal check and what it still refuses, the first run after a fix, and that a record matches bytes, not a session. They also say that the agent's strictest values can differ from the app's, that a crash with no record resumes as before, and that a Settings double failure with no place to record the end leaves only the pending end in the app's memory. Two statements in earlier rounds of this body are corrected here rather than edited in place:Not changed and still limits: a person on the same account can edit or remove any of these files, and an app or agent older than this round does not read the log record. No hardware row was run and no installed app changed.
Greptile on 277b62a (changed in f2298fe)
Greptile's review of 277b62a raised one P1, "Partial end record permits resumption" (
Store.swift:454). If the app or the agent dies while it writes an end record to.recovery.lock, the bytes left count as foreign, not as a possible end. The next agent run empties them, and a relaunch after a repair can resume the session whileSleepDisabledstill reads 1.This body said at 277b62a that no code changed for this finding and that it stayed open. f2298fe changes the code:
Store.lockHoldsRecordCutShort,lock_holds_record_cut_short) count the record of session.json's bytes cut short as a writer leaves it as that session's end: the record's first bytes and nothing else, or the whole record followed by bytes the file held before. The app writes over the old bytes and then cuts the file to length (pwrite, thenftruncate), so a stop leaves the record's first bytes over the old ones or the whole record before the cut. The agent's write leaves the record's first bytes. Other content that is no record still ends nothing, as item 4 has it: other bytes, a record of other bytes cut short past the first byte where the two differ, more than 1 MiB.>>, and leaves the whole record with bytes after it, and a lock file it cannot read, as they are; it then records the end in insomnia.log. It writes with>, which empties the file before it writes, only over content that ends nothing for that session, so a run stopped between the two leaves an empty file where nothing counted before either.remove_stale_lock_recordkeeps the record cut short while session.json is a regular file whose record it holds cut short, and now keeps any content while session.json is there but is not a regular file or cannot be read, as it already kept a lock file it cannot read. Once session.json is gone it empties everything, as before.What remains, not waived:
>and its write. A file size limit cannot stop a shrinkingftruncate, and>runs no command in between. The tests write those states and check what each reader makes of them.Tests in LockEndRecordTests, all fake files and fake commands:
testAWriterStoppedPartwayNeverLeavesLessOfThisEndThanItFound: from six starts (empty, other text, more bytes than the record, another session's record, this record's first bytes, this record with old bytes), the app's real writer is stopped after 0 bytes and at points up to all but one of the record's bytes by a file size limit (RLIMIT_FSIZEwith SIGXFSZ ignored, set around that one write). Each stop leaves the record's first bytes over the old ones, and every stop counts where the start counted. The state before the app's cut and each state the agent can leave are written and read too: from a start that counted every state counts, and the agent's empty file between>and its write counts as nothing and follows only a start that counted as nothing. The real writer then completes each start to the whole record with the inode kept.testARelaunchEndsTheSessionWhoseRecordInTheLockFileWasCutShort: for both forms, with SleepDisabled 1, a relaunch ends the session instead of holding sleep again, logs that the lock file holds this session's end record cut short, removes session.json and empties the lock file in place. A new session then starts, and a crash during it resumes.testTheAgentNeverEmptiesThisSessionsRecordCutShort: with session.json, an unrelated ended-session.json and state.json immutable, no record aside possible and a failing restore, the agent completes the first bytes by appending after a fakermhas set the append-only flag (chflags uappnd) on the lock file, so a>would have failed. It leaves the whole record with old bytes, and a lock file it cannot read, as they are and records the end in insomnia.log. After the repair a relaunch ends each session and empties the lock file, inode kept.testARecordLeftByACleanupWithoutTheLockIsEmptiedByTheNextRunadds a record cut short that a cleanup without the lock leaves, which the next run empties, and content kept while session.json cannot be read.>over the first bytes, rewriting the record with old bytes, writing over an unreadable file, the cleanup emptying the cut-short record, the cleanup emptying content while session.json cannot be read, and the app's writer cutting the file before it writes.Hosted CI split proposal (parent decision, not implemented)
The hosted run on 47bdc6c (job 113426181373) shows the watchdog's 1200 iterations of
kill -0andsleep 1take 1291 s of wall time, not 1200 s. The test build took 95.8 s and tests started 108 s into the step, which leaves about 1183 s for tests. On the 1099 cases it shares with round 27's local full run, hosted took 1.066 times the local time (RecoveryScriptTests 1.02, CutoffAgreementTests 1.31, JournaledSessionEndTests 1.28). This corrects two round 27 figures. The watchdog stopsswift testabout 1291 s after it starts it, not 1200 s. Round 27 projected 1377 s of hosted test time for that head; 47bdc6c finished 1113 cases in 1198.7 s, the 241 cases of round 27's local run it did not finish took 119.0 s locally (about 127 s at the hosted ratio), and the UI and Keychain classes took about 7 s on main, so that head needed about 1330 s, less than projected and still over.Split RecoveryScriptTests by the first letter after
test:swift test --filter '^InsomniaTests\.RecoveryScriptTests/test[A-T]'swift test --skip '^InsomniaTests\.RecoveryScriptTests/test[A-T]'swift test. Each pays its own setup (about 20 s) and test build (96 to 111 s), so a test step needs about 750 to 780 s of the 1291 s.swift build -c release -Xswiftc -warnings-as-errors(about 44 s on main) andscripts/check-lid-simulation-gate.sh(about 42 s). No RecoveryScriptTests case uses tmux, so job A can skip it.swift test list --skip-build), selects its IDs with the same regex (grep -Ein A,grep -vEin B) and checks that the IDs its log shows as started and passed equal that selection, with no ID twice and a count above zero. B's selection is the complement of A's over the same list, so together they run every case once. Job B also checks that the two counts add up to the list's.The projections come from local runs and two hosted logs (the "1274 to 1280 s" above uses two methods: local times scaled per class, and 47bdc6c's hosted times scaled by this round's local change). They are not a hosted pass, and the split needs a hosted run at the new head before anyone relies on it. A longer watchdog is the other choice; this round did not change the workflow. The ci.yml comment that a normal run takes about 3 minutes is out of date (main b5f7cf0 took 878.8 s of test time) and was left as it is.
Tests for round 29
.1, over the limits); rotations under the lock carry a record forward while its session.json is there; a rotation waits while it cannot tell whether.1holds a record in force; the tick ends a session the agent ended in the log; an app end and an agent end recorded only in the log are not resumed or revived after a full repair; a crash resumes beside records of other bytes and lines that are no whole record; a refused Settings change that cannot be put back records the end in the log.testAStaleRecordEndsNoNewerSessionAndOnlyContentThatIsNoWholeRecordDoesis nowtestAStaleRecordOrContentThatIsNoRecordEndsNoNewerSession, andtestContentThatCannotBeReadWholeCountsAsTheEndUntilSessionJSONIsGoneis nowtestContentThatIsNoRecordEndsNothingAndAnUnreadableFileCountsAsTheEnd. The every-shape table expects content that is no record to end nothing, on both sides.testUninstallFindsItsCheckoutWithoutPATHOrCDPATH(item 3),testUninstallPurgeKeepsTheLogsWhileSessionJSONIsThere(item 1) andtestWholeNumbersWrittenWithAFractionReachTheBinaryAsDigits(item 2; its control, a pid of 5100.5, is refused and runs nothing).testTheAppTheBinaryAndBothScriptsAcceptTheSameJournals: rows the check now accepts (a pid written5105.0, a bad escape under a key the app does not read) expect acceptance, and new rows cover whole numbers with a fraction or exponent, numbers past 2^53 or that aDoublerounds, keys twice where the app reads nothing, values the app skips, and UTF-16 and UTF-32 with and without a byte order mark.refuseLogRecord), so they still cover that case, and their log text adds "or the log file".OwnerOnlyTests.testLogPastTheCapRotatesToDotOneAndStartsAFreshFileholds the recovery lock, since a line written without it no longer rotates.testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwnruns the scripts' code instead of matching thedirnameline (item 3).Store.lockRecordWriteLimitForTesting, lets tests make the app's lock record write fail, asPatchedBackstop.refuseLockRecorddoes for the agent. Release builds compile it out.Round 29 verification
Every focused run below ran
swift test --skip-builddirectly, without the shared test lock, with an anchored filter and--skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests. Discovery wasswift test list --skip-buildwith the same three flags. That command exits 0 but still lists the 69 IDs of the three classes, so the script removed them by name and refused an empty selection or one outside the named classes before it ran anything. Each check after a run compared the started names with the selection; none of the three classes started and no name started twice.Focused runs, all on drafts of this round's change over 47bdc6c, uncommitted (the full runs below are the evidence for the committed tree):
updateConfig, so Settings refused the change early. The denies now apply just before the old record is put back (beforeRecordedCutoffsPutBack).cat, and the fakelaunchctl bootoutexited 5. The stand-ins now answer only uninstall.sh.1e-99999as 0 for an Int32, so the app loads that row.pmset -g log).concurrentRow()and the 5 s hung-binary limit.The catalog at 277b62a lists 1424 IDs, 69 of them in the three skipped classes, so 1355 run locally (round 27: 1340).
Full runs used the shared lock (
/usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests) with swift as lockf's direct child, on the clean tree at 277b62a (source fingerprint 4a0f4172, 136 files), with the lock's inode 201732085 the same before and after.RecoveryScriptTests/testSessionWithOffsetDatesIsReadLikeTheApp, a test this round did not change. The fakesudodid not finish within the fixture's 1 s limit, so the backstop exited 1 with no call logged.pmset -g logshows the Mac entering Clamshell Sleep on battery at 14:13:17 PDT and Maintenance Sleep at 14:15:08, with DarkWake at 14:15:11; that backstop run started at 21:15:09Z (14:15:09 PDT). RecoveryScriptTests took 1104.7 s in that run. Right after, on the same clean tree, the case passed 3 of 3 alone (21:24:03Z to 21:24:13Z).pmset -g logshows Clamshell Sleep at 14:32:03 PDT, a Thermal Emergency Sleep at 14:32:16, then Clamshell and Maintenance Sleep until the lid opened at 15:03:25.testEndFloorIsReadFromConfigAndZeroDisablesItfailed when the fake app binary missed its 1 s limit at 21:41:49Z, one second before the wake at 14:41:50 PDT that ended a Maintenance Sleep begun at 14:39:05.testInstallStopsAndLetsGoOfTheLockWhenLaunchctlPrintDoesNotAnswertook 652 s against its 45 s bound, across the 641 s Maintenance Sleep from 14:52:44 to 15:03:25. Neither test changed in this round. Run alone three times each, the install test passed 3 of 3 (17.6 to 19.6 s). The end floor test passed twice (84.8 s and 4.7 s) and failed once, taking 56.7 s against its usual 4.7 s, during the Maintenance Sleep that began at 15:14:38, after the lid closed again at 15:12:31.pmset -g log, and none of the failed tests changed in this round, but that is a reading of the timing, not a pass. A third full run was not started while the Mac kept sleeping with its lid closed, because it would hold the shared test lock through the sleeps.bash -nunder /bin/bash 3.2.57 passed on all 7 scripts, and the bash 4 grep found nothing. These ran at 20:55Z, before the commit. No script changed after 12:33 PDT, and the tree was clean right after the commit, so they checked the committed scripts.swift build -c release -Xswiftc -warnings-as-errorsexited 0 with no warnings (20:54:50Z to 20:55:14Z). That build was incremental, on the working tree before the commit; no source file changed after 12:21 PDT. scripts/check-lid-simulation-gate.sh exited 0 on the clean committed tree (21:24:47Z to 21:25:03Z): its plain release build found everything up to date, and its lid simulation build compiled with the same flags and no warnings.f2298fe verification
The focused runs used the same discovery, filters, skips and checks as the round 29 runs above. All ran on drafts of f2298fe over 277b62a, before the commit. The last one, p1-lock4, ran on the same file contents as the commit: its source fingerprint (427805ae, over Sources, Tests and scripts) is the one the clean committed tree gives.
Mutants (no M8, the number was skipped), each killed: M1 and M2, either reader ignoring the record cut short; M3, the agent writing
>over the first bytes; M4, the agent writing the record again over the record with old bytes; M5, the cleanup emptying the record cut short; M6, the app's writer cutting the file to zero before it writes; M7, the agent writing over a lock file it cannot read; M9, the cleanup emptying content while session.json cannot be read. The working tree's diff was the same before and after each attempt.Full run 3 used the shared lock as above (
/usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests), with swift as lockf's direct child, on the clean committed tree at f2298fe, with the lock's inode 201732085 the same before and after.testUninstallAbortsWhenAgentIsStillLoadedAfterBootoutandtestUninstallAbortsWhenBootoutAndPrintBothFailAmbiguously. In each, the backstop run inside uninstall.sh exited 1 withsleepDisabledByUsstill journaled, so uninstall stopped before it calledlaunchctl. Neither test writes session.json, and the lock file is empty there, so the lock record code f2298fe changed returns at once.pmset -g logshows Clamshell Sleep on battery at 17:06:21 PDT, a DarkWake at 17:10:27, Maintenance Sleep from 17:11:13 to the lid wake at 17:23:36, and Idle Sleep from 17:26:46 to 17:26:57. By the case times, the first failure ran from about 17:11:11 to 17:23:37 (745 s; it takes about 5 s alone) and the second from 17:23:37 to 17:23:43, in the first seconds after the wake. Run alone three times each right after, on the same clean tree (00:29:52Z to 00:30:24Z), both passed 3 of 3 in 4.2 to 5.3 s.pmset -g log, and none of the failed tests changed in this round, but that is a reading of the timing, not a pass. The Mac sleeps when its lid closes, and nothing here keeps it awake, so a clean full run on an awake Mac is still owed.bash -nunder /bin/bash 3.2.57 passed on all 7 scripts, the bash 4 grep found nothing, and ShellCheck 0.10.0 exited 0.swift build -c release -Xswiftc -warnings-as-errorsexited 0 with no warnings on the clean committed tree (00:30:38Z to 00:30:51Z). scripts/check-lid-simulation-gate.sh then exited 0 (00:30:51Z to 00:31:06Z): its plain release build was up to date and holds no watcher symbol, and its lid simulation build compiled with no warnings and holds the watcher.>and its write; those states are written by the tests and read by both sides.Round 27: review of bec766b
An independent review of bec766b (GPT-6.1-Sol) returned NEEDS CHANGES. It confirmed three Greptile findings (4219151866, 4219151883, 4219151895), kept two earlier limits open, and traced the hosted CI failure to the workflow's 20-minute watchdog. Main took #43 (b5f7cf0) during the review, and this round merges it. 844947d fixes findings 1, 2 and 5. 958ba1a fixes finding 3, and 47bdc6c puts back two of main's uninstall.sh lines that 958ba1a had changed. 277b77f fixes ShellCheck warnings, and 1e1178b shortens fixture timing for finding 6. Finding 4 was assessed and not changed; it stays under "Not covered", not waived.
--agent-session-cutoffsread"30 false"from a state.json whosefrozenProcesseswas"bad", a journalStore.loadStaterejects. The agent ended the session and removed session.json, then found the journal malformed and undid nothing, so sleep stayed disabled with no session left. Now:check_journal: plutil, the shape checks andrecord_text_problems) before it reads the cutoffs from it, and before it ends a valid session, removes session.json or records an end,--forceincluded. A journal that fails stops the run with session.json, the journal and every undo entry kept byte for byte, records no end anywhere, runs nothing and exits 1 with a log line (refuse_malformed_journal). The first run after a repair ends the session.record_text_problems, byte-identical in both scripts, now reads every object and array at any depth, not only the top level and Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's records. It refuses a key of letters found twice in one object (escapes decoded, a Kelvin sign read as K), an escape JSON does not have, a value that is no JSON value, and a number outside Float, Int32 or Int64 where the app reads that type. Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's checks of the kept display records keep their rules; only their log wording changed.--agent-session-cutoffsdecodes the whole journal withStore.decodeState, the decoderStore.loadStateruns, before it reads the record. It answersrejected(exit 65) for a journal the app does not load, which stops the run the same way, andforeign(exit 65) for a record the app does not write.InsomniaAgentCutoffsVersionis 3, so the agent never asks an older bundle's binary.1.0, a bad escape or number under a key the app does not read, NUL bytes, UTF-16. One table of 33 journals checks that the app, the binary and both scripts agree on that rule.endSessionOnDisk, the same removal and recordsperformEnduses): session.json is removed, or, when it cannot be, its end is recorded. The app then ends it in process ascutoffsNotRecorded, and Settings says the session ended. The cost is that a Settings change during this double failure ends the session.cat,grep,head,tr,statandidfrom PATH in its readers, and uninstall.sh tookcat,head,tr,awk,id,basenameanddirname. This PR added thecatinrun_read, both batterygrepreads, the two end record copies, the cutoff answer excerpt and oneheadcheck; the rest came from main. Each now uses its declared absolute path (CAT,GREP,HEAD,TR,AWK,ID,STAT) or parameter expansion, except two uninstall.sh lines that read no state and that main's tests compare word for word with install.sh.dirnamefinds the script's own folder, andcatreads asudocall's pid inbounded(), which uninstall.sh never runs forsudo. 958ba1a had changed both, the full run on 277b77f failed main's two tests for them, and 47bdc6c puts main's text back.sleepstays by name too: it reads nothing, and main's slow-poll tests replace it through PATH.run_readkeeps fd 9 closed, and undo commands keep Backstop: the supervisor owns each undo command's limit and signal #50's supervisor. No root exploit was shown or is claimed.sessionCutoffsfrom the journal it has checked (journal_cutoffs, which takes only the text the app writes: a floor of 0 to 95, a space andtrueorfalse), enforces that record and logs that it read it. A journal with no record gives the defaults (10%, on) when config.json is missing or rejected, as before, and the strictest values when config.json is there and only the binary failed on it, since the agent cannot tell then what the app enforces. A record the app does not write still gives the strictest values. The reviewer's traces hold: with config.json rejected ("freezeList": 42) or missing, a session on 30% with thermal rules off ends at 20% and 29%, and stays at 31% and 40%.Merge of main. 7a28ce0 merges b5f7cf0 (#43) with a merge commit. The conflicts were resolved so both sides keep their behavior:
SessionManager.performEnd: an incomplete end settles the pending end only when Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43'sagentCanFinishholds (agent armed, no hidden failed restore, no owed lit read, no owed settlement); otherwise it schedules the retry. This PR'ssettlePendingEndandjournalNeedsRestoreboth stay.RuntimeState:undoEntriesleaves out Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's kept display records and this PR'sendedSessionandsessionCutoffs, andCodingKeyslists all of them.journal_shape_problems: this PR'sendedSessiontype check, and Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's kept display checks withrecord_text_problems.remove_session.--purge: state.json stays while it holds a refused brightness (Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43), and ended-session.json, records aside and the lock file record are removed or emptied (this PR).EarlierBootLowPowerClaimTestsseeded a running session over a journal without its sleep hold, and this PR's reconcile ends such a session at launch, so its three in-session routes ended at reconcile. The fake now reports the hold for those routes, as a restart keeps it. No assertion changed.Docs. README, SECURITY.md, spec sections 6, 8 and 10,
.greptile/rules.mdand one release validation row describe the journal check, the agent's own read of the record and the session that ends on a failed rollback. Two claims are corrected. The README no longer says every record must be a file you own and not a link: ended-session.json counts as session.json does, so a link there is followed to a regular file and its owner is not checked, while a record aside and the lock file need a regular file this user owns, not a link. The spec and this body no longer say a failed rollback leaves both sides on the old cutoffs.Tests for round 27
testAJournalTheAppDoesNotLoadKeepsAValidSessionTheRunWouldEndruns 7 journals the app does not load or reads differently from plutil (the reviewer's"frozenProcesses":"bad", keys twice at the top level and nested, an escaped duplicate, a pid past Int32, a record twice, a cut-off file) in 4 modes (app alive with config.json missing or read, app not running,--force), all at 20%. Each run exits 1 with session.json and the journal byte for byte, no end recorded in any place, nosudoorkill, and the log naming the cause. After a repair the next run ends the session.testAJournalTheAppLoadsLetsTheRunEndAValidSessionis the control: the journal this build writes and an older build's journal end in each mode, at 20% on the record's 30% floor and at 9% on the defaults.testTheAppTheBinaryAndBothScriptsAcceptTheSameJournalsreads 33 journals throughStore.decodeState, the binary's answer and both scripts' checks: the scripts accept only journals the app loads, and on each one they accept, the agent's own reader gives the same record as the binary. Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43'stestTheRecordReaderFollowsTheTopLevelAsTheAppReadsItgains 17 rows (keys twice at any depth, Kelvin signs, Int32 and Int64 ends, numbers and escapes JSON does not have).testACutoffChangeWhoseRecordCannotBePutBackEndsTheSessionreplays the double failure in both directions (30% to 10% with thermal rules off, 10% to 30% with them on), with session.json removable and immutable. It checks that the session ends on disk before the lock is released, that copies of the disk taken at that moment have the hung app's agent restore sleep and a relaunch resume nothing, and that the app ends the session ascutoffsNotRecorded. The controls put the record back, and the agent then enforces the old cutoffs at 20%.testTheAgentReadsTheRecordItselfWhenTheAppBinaryCannotAnswercovers a session on 30% with thermal rules off: it ends at 29% and stays at 31% at critical heat with config.json rejected, missing, or one the binary could not read. A journal without a record gives the defaults when config.json is missing and the strictest values when it is there.testTheAgentKeepsTheSessionWhenTheAppsBinaryRejectsTheJournalhas a stand-in binary answerrejected, and the agent stops with the session and the journal kept.foreign. 9 more journals that the app rejects for another key answerrejectedand failStore.loadState, and the built binary answersrejectedfor one.testUninstallAndItsBackstopTakeNoToolFromPathrun the real scripts on twin homes, once with the usual PATH and once with stand-ins first in PATH that would answer a 0% floor, a full battery or a file that is not JSON. Both runs match and no stand-in runs.dirnamehas no stand-in, for the reason above.PatchedBackstop's fakes now call/bin/cat, andfailLockReadBackfails only the read of the lock file.testTheAgentReadsTheRecordAsTheAppDoeshad three rows withsessionCutoffstwice, which the agent read as the app does (ending at 20% on the first copy's 30%). The agent now refuses those journals, so the three rows moved to a loop that expects exit 1 with the session and the journal kept. Two rows with one escaped key replace them, and one log text names the new cause ("a value the app does not write").AgentCutoffsCommand.versioninstead of a literal 2.rejectedanswer. The 33-journal table stopped at its first assertion, because the old scripts have nojournal_cutoffs.Round 27 verification
Every focused run below ran
swift test --skip-builddirectly, without the shared test lock, with an anchored filter whose names were listed first and--skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests. Each check after a run compared the names that started with the list, and none of the three skipped classes started.EarlierBootLowPowerClaimTestsroutes fixed in 2acc91d. The class then passed 13 of 13.testJournalWithSessionCutoffsTheAppDoesNotWriteIsStillUsablefailed 10 assertions: uninstall exited 1 after only twopgrep -x Insomniacalls. That test does not use the changed fixtures, and it passed in every later run on committed source. The cause is not known.contains(""), and one test wrote an older journal from the wrong source. After the fixes the 27 passed, in the ShellCheck run below.INSOMNIA_HOMEto itself, so the app log lines the test checks went to the copy.diskCopynow points it back, and the 2 rollback tests passed on source equal to 844947d's (same fingerprint).record_text_problemsin both scripts, and SC2016 in uninstall.sh from 958ba1a). 277b77f fixes them with no change in behavior, and the 27 finding 1 and 5 tests passed on source equal to 277b77f's, before it was committed (same fingerprint)./usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, swift as lockf's direct child, clean tree, lock inode 201732085 unchanged). On 277b77f, 1340 cases started and ended, 1338 passed and 2 failed, in 1230.7 s. Both failures were main's word-for-word checks of uninstall.sh against install.sh (testInstallAndUninstallShareTheBoundedCallHelper,testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwn), which 958ba1a broke and no focused run had covered. 47bdc6c put main's lines back, and 16 tests on its source passed, the two checks and both PATH tests among them. On 47bdc6c the same 1340 cases passed with 0 failures, in 1243.3 s. None of the three skipped classes started in either run, andpmset -g logshowed no sleep or wake during either.cutoffs 30 false. The binary now answersrejectedwith exit 65, and the agent kept session.json and the journal byte for byte and exited 1. The reviewer's PATH probe failed its 2 checks that the stand-ins ran. They did not run, because the scripts now call fixed paths, and the session still ended at 20% with each stand-in first in PATH. A first fault run used /usr/bin/swift, which dropsDYLD_INSERT_LIBRARIES, so its fault never applied and its results count for nothing. The second ran the toolchain's swift directly, which applied it.bash -nunder /bin/bash 3.2.57 passed on all 7 scripts, the bash 4 grep found nothing, ShellCheck 0.10.0 exited 0,swift build -c release -Xswiftc -warnings-as-errorsexited 0 with no warnings, and scripts/check-lid-simulation-gate.sh exited 0.Round 25: review of 252557d
An independent review of 252557d (GPT-6.1-Sol) returned NEEDS CHANGES with three P1 findings, and Greptile comment 4217047024 repeats the third. All three are addressed in bec766b. One narrower case stays open, the one the review itself names: when every place that could hold the record refuses the write. It is under "Not covered".
~/Library/Logs/Insomniaand made session.json, an unrelated ended-session.json and state.json immutable. The agent ended the session with the app alive but stopped, and could record the end nowhere. After every flag and both ACLs were repaired, an app launched first withSleepDisabled 1(from a failed restore, or set again later by another program) resumed the same bytes and sentdisablesleep 1. The reviewer showed that the recovery lock file.recovery.lock, which already exists, still takes a write in that state. It is now the last place for the record:ended-session-v1, a space, session.json's bytes in base64 and a newline. The agent writes it withprintfwhile it holds the lock on fd 9. The app writes it through the descriptor of the lock it holds (RecoveryLockHandle.replaceContents: pwrite, then ftruncate, then fsync), so a write cut short leaves bytes that are no whole record, never an empty file. Each writes only while the path is a regular file this user owns, not a symlink, with the inode of the lock it holds, and reads the record back before it undoes anything. Nothing unlinks or replaces the file, so the lock and the descriptor a privileged command inherits are unchanged.Store.lockEndRecord) and the agent (read_lock_record) read the file the same way. Empty, missing, or not a regular file this user owns is no record. A whole record ends only the session.json with exactly those bytes. Anything else (a write cut short, other bytes, more than 1 MiB, a file that cannot be read whole) counts as the end of whatever session.json holds until that file is gone. A record the agent wrote but could not read back is not counted by that run, which keeps the journal entry and exits 1. The next run counts the file as the end whether it reads it whole or not, and so does the app.--purge. 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.disablesleep 1, removes session.json and empties the lock file, whose inode stays the same.cmpdid not report the same bytes. A session.json it could not read therefore cost the only record of the end, and the session resumed after the repair. The agent and uninstall.sh now use the rule they already used for a record aside: the record goes only when session.json is gone orcmpreports other bytes (exit 1). Whilecmpcannot compare (exit 2) or session.json is not a regular file, the record stays and ends nothing. The app already agreed: it removes ended-session.json only after it has removed session.json. One difference between the readers remains, unchanged by this round. The app and the agent follow a symlink at ended-session.json and require a regular file there but check no owner, while the readers of a record aside and of the lock file refuse a symlink and require this user as the owner."freezeList": 42, which makes the decoder reject the whole file. The agent then enforced the defaults (10%, on) and kept the session at 20%. A removed config.json did the same. The journal now records the cutoffs in force for the session:RuntimeState.sessionCutoffs, written as"30 false". Start writes it together withsleepDisabledByUsbefore anything runs for the session, and reconcile writes it before it resumes one. Every transaction that checks the session, and the tick, record it again when the journal holds other cutoffs or none (a hand edit the app took in, a write that failed earlier). A session whose cutoffs cannot be recorded ends (cutoffsNotRecorded). A Settings change to a cutoff during a session takes the recovery lock without waiting and records the new cutoffs before it saves config.json. A busy lock, a journal that cannot be read or a write that fails refuses the change, and a config.json save that fails puts the old record back. (Correction in round 27: when the record could not be put back either, the journal kept the new cutoffs while the app stayed on the old ones, Greptile 4219151883. The session now ends in that case.) The app clears the record when it removes session.json. It is a record, not an undo entry, so it never makes the journal dirty.Insomnia --agent-session-cutoffs 33, which decodes state.json with the app's own reader and answerscutoffs <floor> <true or false>,noneorrejected. The new mode keeps every property of--agent-cutoffs: no side effects, version, output and exit checks, the 8 MiB limit, the 30 s read and 33 s alarm, and fd 9 closed. No record, or no state.json, gives the defaults. A value the app does not write, a state.json that is not a readable regular file, or a binary that cannot answer gives the strictest values (95%, on) with a log line. (Correction in round 27: when the binary cannot answer, the agent now reads the record itself, and a state.json that is not a readable regular file stops the run with the session kept.)InsomniaAgentCutoffsVersionis now 2, so the agent never asks an older bundle's binary for the new mode. The agent and uninstall.sh leave the value as it is, and their journal shape checks ignore it, so a journal with any value there still undoes.1e-400(0) and4.9999999999999999(5), the Int endpoints, the version marker and the notices are unchanged, and CutoffAgreementTests keeps one expected value per row.Docs. README, SECURITY.md, spec sections 6, 8 and 10,
.greptile/rules.mdand docs/release-validation.md (one row corrected for the lock file record, three added) describe the record in the lock file, the canonical rule andsessionCutoffs.Tests for round 25
Round25ProbeTests, 6 tests, not committed; the source is kept with the evidence) assert the required behavior through the real manager, Store and a patched copy of the real backstop.sh. At 252557d's source 5 failed with 9 assertions: both full-repair cases resumed and sentdisablesleep 1, the unreadable ended-session.json case deleted the record and resumed, and the rejected and missing config cases kept the 30% session at 20%. The controls (a valid 30% file at 20%, a rejected file over a 10% session at 5%, a rejected file over a 30% session at 40%) passed. On bec766b's source all 6 passed (exit 0, 82 s).loadState, the journal form round-tripping every cutoff, and the built binary answering for the journal without starting the app or writing a file.testEndWhereNeitherFolderTakesANewFileRecordsNothingAndKeepsTheJournalwith a test of the record in the lock file when both folders refuse, and adds five: the lock file taking the record when it is the only place left, nothing recorded when the lock file refuses too, an exact ended-session.json kept whilecmpcannot compare, uninstall in both modes emptying the record in place (and stopping before it removes anything with a symlink at the lock path, naming one put there during the run), and a journal with 7sessionCutoffsvalues the app does not write still undone by the agent and both uninstall modes. Two existing tests change: the case that records nothing now also refuses the lock file record, and the symlinked log folder case now expects the record in the lock file.Round 25 verification
Every focused run below ran
swift test --skip-builddirectly, not under the shared test lock, with an anchored filter whose names were listed first and--skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests. The full run took the lock with swift as lockf's direct child. None of the three skipped classes started in any run.extensions, so Start refused before the rollback; one test read an empty log; one expected the wrong log line./usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with swift as lockf's direct child and a wrapper outside lockf that only recorded the head, tree, status and lock inode. Queued at 12:26:14Z, the lock was free and the suite started at 12:26:19Z; it ended at 12:44:42Z with exit 0: 1187 tests, 0 failures, 1103 s. Of 1256 listed tests, 69 are in the three skipped classes and none of them started, so executed equals listed minus skipped. The head, tree and clean status were the same before and after, and the lock file kept inode 201732085.pmset -g logshows no sleep or wake in the window (the last wake was 2026-10-07 17:29:55 PDT).bash -nunder /bin/bash 3.2.57 passed on all 7 scripts, the bash 4 grep found nothing, ShellCheck 0.10.0 exited 0,swift build -c release -Xswiftc -warnings-as-errorsexited 0 with no warnings, and scripts/check-lid-simulation-gate.sh exited 0 (the lid code is compiled out of the plain release build and in withINSOMNIA_LID_SIMULATION).Round 23: review of bf71148
An independent review of bf71148 returned NEEDS CHANGES with four findings, and Greptile comment 4215544412 repeats the fourth. All four are addressed in 252557d. The first is closed for the reviewer's case and narrowed for the rest. The limit that remains is under "Not covered".
Sources/Insomnia/Core/SessionManager.swift, reconcile step 2). The reviewer made session.json, an unrelated ended-session.json and state.json immutable and added a deny-add-file ACL to the Application Support folder. The agent then ended the session with the app gone, but it could not publish any record. After a failed restore, or a later hold by another program, the reviewer removed the ACL, made state.json writable again and launched the app first. The app resumed the same bytes and sentdisablesleep 1. Two changes:~/Library/Logs/Insomnia, under the same name shape (mktempin the agent,O_EXCLin the app, mode 0600), and keep it only when it reads back identical. Both write and read it back before they undo anything. The log folder counts only while it is a directory owned by this user and not a symlink, and a record there only when it is a regular file owned by this user with session.json's exact bytes. Reconcile, the 1 Hz tick, the adoption of the agent's end, every agent run and uninstall.sh (with and without--purge) search both folders. Stale records there are removed by the same rules as beside session.json. That closes the reviewer's case: the record survives the repair, and the relaunch restores instead of resuming.SleepDisabledreads. The cost is that a crash while session.json cannot be replaced ends the session at the next launch instead of resuming it.scripts/backstop.sh,config_int). The app's decoder takes the first of twoendFloorkeys and plutil the last, so{"endFloor":95,"endFloor":0,...}gave the agent 10 and the app 95.endFloorin either place did the same, and a duplicatethermalRuleskey turned the agent's critical-heat end off while the app kept it on.lowPowerFloor:"bad",presets:["bad"],lowPowerFloor:-9223372036854775809) withendFloor0 andthermalRulesfalse let the agent read endFloor 0 and skip the cutoff at 5% while a hung app ran on 10%.endFloortexts the decoder rounds read higher on the agent's side:1e-400is 0 to the app and 10 to the agent, and4.9999999999999999is 5 to the app and 10 to the agent.R2 to R4 share one fix: the shell no longer parses config.json.
config_number_text,app_int_value,config_intand the plutil reads of the two cutoffs are gone. While a session is valid and the app holds the alive lock,read_cutoffsopens a readable regular config.json once and passes its bytes on standard input to the installed binary,Insomnia --agent-cutoffs 33. That mode (AgentCutoffsCommand, answered inmain.swiftbefore AppKit starts) arms a SIGALRM for its lifetime, reads at most 8 MiB, decodes the bytes withStore.decodeConfig(the callStore.loadConfigmakes), printscutoffs <endFloor> <thermalRules>fromConfig.agentCutoffsand exits. It takes no lock, opens none of Insomnia's files, writes nothing and calls no private API, so it answers while the app's UI is hung. It runs as one of the bounded reads, with fd 9 closed, SIGTERM after 30 s and SIGKILL 3 s later. backstop.sh runs it only when the bundle's Info.plist declaresInsomniaAgentCutoffsVersion1, as it does for--resume-frozen. A missing, non-regular or unreadable file and the answerrejectedgive the app's defaults (10%, on). Any other outcome (binary missing, another declared version, no answer in time, more than 8 MiB, other output) gives the strictest values, a 95% end floor with thermal rules on, and a log line naming the cause. The test table in CutoffAgreementTests is back to one expected value per row, compared for equality on both sides.Docs. README ("How recovery works" and the location table), spec sections 6, 8 and 10, SECURITY.md and five new "Not run" rows in docs/release-validation.md describe the app-binary read, its fallback, the record in the log folder, the replace check, its crash cost and the limit that remains. Two ConfigTests comments that described the old reader are updated.
Tests for round 23
ReviewerRound22ProbeTests, 7 tests) ran unchanged at bf71148's source: 6 failed with 13 assertions. The failed-restore and other-hold cases resumed and sentdisablesleep 1, the duplicate and escapedendFloorrows kept the session at 25% on the agent's side, the duplicatethermalRulesrow kept it at critical heat, the three rejected objects kept it at 5%, and both rounded texts ended a session the app keeps. The ordinary controls passed. After the fix the same file ran with one change, disclosed inprobe-after/adaptation.diff: the reviewer'snoNewFileEndasserted that no record exists anywhere, and the fix now writes one, so it asserts one record in the log folder with session.json's bytes. Two evidence-only probes were added: every location refusing with session.json still locked after the repair (ended, nodisablesleep 1), and the same after session.json is unlocked too (resumed, the limit under "Not covered"). All 9 passed. The probe file is not committed.endFloorandthermalRuleskeys and ordinary values (95, 0, 10,{}) through the real app (loadConfig, reconcile, start) and the real backstop.sh, which runs the built test binary's--agent-cutoffs; each asserts the same cutoffs and the same end decision on both sides, and no "strictest" line. One covers the three rejected objects at 5% (the agent ends on the 10% default) with the app's own settings as the control. One covers1e-400and4.9999999999999999before and after a canonical save. One makes the binary unable to answer in seven ways (version withdrawn, binary missing, an answer out of range,rejectedwith exit 0, exit 65 with acutoffsline, exit 1, and a hang past a 1 s limit): each ends at 94%, keeps at 95%, ends at critical heat with the thermal rule off, and logs the cause. The value tables are back to one expected floor per row, compared for equality.Store.loadConfigrow by row) and as the built binary (an answer with no app started and no file written, the 8 MiB limit, SIGALRM at the end of its lifetime, and the version being the same in Info.plist and backstop.sh).Storewriting in the log folder only when its own folder refuses.testEndInAFolderThatTakesNoNewFileRecordsNothingAndKeepsTheJournalwith a test of the record in the log folder, and adds five: both folders refusing (nothing recorded, journal kept, exit 1), stale records in the log folder, a symlinked log folder that is neither searched nor written through, uninstall in both modes removing records there, and a config.json with mode 0 giving the defaults.Round 23 verification
Every run took the shared test lock with
/usr/bin/lockf -kand passed--skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests; none of those three classes started. (Correction in round 25: the first half of that sentence is false. The focused runs below ranswift testdirectly without the lock. The two full runs and the three solo reruns took it with/usr/bin/lockf -k, with swift as lockf's direct child. Every run did pass the three skips.) The lock file kept inode 201732085 before, during and after each full run.ended-session.jsonthat the first relaunch had written. The test now removes it first, and the next run passed it. RecoveryScriptTests together with the crash test passed 262 of 262. Before that, the first CutoffAgreementTests run failed 3 of 19 on a test helper that carried the fake call log from one sub-case into the next.testValidSessionWithTheAppAliveAndAHealthyMachineIsLeftAlone(the backstop ended the valid session before any read) andtestZeroMicrosecondsStillUsesTheAppBinary(no call from the fake app binary) failed. They were the last two RecoveryScriptTests cases, and they ran during a slowdown in which the last four cases of that class ran 1.5 to 14 s slower than in the focused run.pmset -g logshows no sleep then. The second failure matches the recorded fixture flake where a fake does not answer within the fixture's limit under load. The cause of the first was not determined, because its assertions print the backstop log only when the exit status is wrong. Both passed alone three times, and the second full run passed 1156 of 1156 in 988 s. (Correction in round 25: the cause of both failures, 2 cases with 7 assertions, is unknown. The second resembles the recorded fixture flake, but that was not shown, and the three passing solo runs and the passing second full run do not explain either failure.)swift build -c release -Xswiftc -warnings-as-errors(0 warnings), ShellCheck 0.10.0,bash -nunder bash 3.2.57 for all seven scripts, and the lid gate all pass.Round 21: review of 953b4b0
An independent review of 953b4b0 (GPT-6.1-Sol) returned NEEDS CHANGES with two findings. Both are fixed in bf71148.
Sources/Insomnia/Core/SessionManager.swift, reconcile step 2). The reviewer made session.json, an unrelated ended-session.json and state.json immutable, with the folder and the log still writable, and had the agent end the session. Round 19's check ends such a session at relaunch whenpmset -greadsSleepDisabled 0. In two cases the bit still reads 1: the agent's own restore failed, or another program helddisablesleep 1. Afterchflags nouchgon state.json, a relaunch resumed the ended session and sentdisablesleep 1. A 1 does not show that the session is still live, so the fix records the end instead of relying on the bit. The folder still takes new files, so when the three files refuse the write, the agent copies session.json to a new file beside them,ended-session.json.XXXXXXXXfrommktemp(mode 0600), and keeps it only whencmpfinds the same bytes. An existing match is used again. The app's own end does the same withO_EXCLand a random name, up to eight tries. Reconcile, the 1 Hz tick, the adoption of the agent's end, and the agent's "already ended" check take a regular file of exactly that name shape with session.json's exact bytes as a third record, after ended-session.json and the journal. A symlink, FIFO, directory, unreadable file or other name is not one. Every agent run removes a record aside whose session.json is gone or holds other bytes, and keeps it when session.json is not a regular file or cannot be read, so a read failure never deletes the record.deleteSessionremoves the records with session.json, and uninstall.sh removes them with and without--purge, naming any it cannot remove. A folder that takes no new file is the case left; see "Not covered".scripts/backstop.sh,config_int). plutil rounds-9223372036854775809, which the app's decoder rejects, to -9.2233720368547758e18. Round 19's reader took that as -2^63 and clamped it to 0, so with the app hung on 10% the agent enforced no battery cutoff. In the hand-written lists below, 953b4b0 read 46 texts lower than the app: values near -2^63, and JSON5 forms that plutil accepts and the app rejects (+5,5.,0x5,.5e1). The JSON5 forms already read low at b96f62a, which read 31 texts of the same lists lower than the app (-5as -5, for example), so those are not new in 953b4b0.config_intnow reads the number's own text first.config_number_textis a strict JSON tokenizer that finds the one top-levelendFloornumber. It refuses a file over 16 KiB, more than 4096 tokens or 64 levels, a NUL anywhere, a control character (DEL too) in a string, JSON5 forms, and a top-level key that is escaped or appears twice (the decoder takes the first, plutil the last).app_int_valuethen applies the rules measured on the app's decoder: an integer anywhere in theIntrange, or a whole number with a fraction or exponent from -9223372036854775807 through 9223372036854775295, at most 64 characters, with an exponent of at most two digits (five for zero). A proven text gets the app's value, clamped to 0...95. Any other text gets plutil's reading, but never less than the default 10%.Docs. The spec's reboot paragraph said a session resumes after a reboot. It now says the session resumes only if
SleepDisabled 1survived the reboot, which was not measured, and is ended otherwise. The README, the spec and the backstop.sh header no longer say an agent end never leaves a session a relaunched app would resume; they describe the three records and the case left. docs/release-validation.md updates the all-locked row for the record aside and adds rows for a folder that takes no new file and for the rejectedendFloor, all Not run.Tests for round 21
ReviewerRound20ProbeTests, 4 tests). At 953b4b0 three failed with 5 assertions: the failed-restore and other-hold cases each resumed the session and sentdisablesleep 1after the journal repair, and the agent kept the hung app's session at 5% on the rejected floor. At bf71148's source all four pass.testAnAgentCutoffThatCanWriteOnlyANewFileIsRecordedAsideAndNotResumedlocks the three files and runs the real backstop.sh. It checks that the record aside (mode 0600) exists before the fakesudoruns, that a relaunch does not resume, that the next agent run reports "already ended" without reading the battery and reuses the record, and that once everything is unlocked the agent removes both files and exits 0. Two tests port the reviewer's failed-restore and other-hold cases with the record aside, then repair the journal and relaunch with the fake reportingSleepDisabled 1: no resume and nodisablesleep 1. A third repairs every file after a failed restore. The others cover the app's own end, which writes the record before the restore and is honoured by a relaunch and the agent; the running app's tick; a record of an earlier session, which ends nothing; a symlink, FIFO, unreadable file and wrong name, none of which count; andStorewriting one record and removing it with the session.testEndThatCanWriteOnlyANewFileRecordsItAsidechecks the record and the log.testEndInAFolderThatTakesNoNewFileRecordsNothingAndKeepsTheJournalmakes the folder 0555: the run calls the restore, cannot confirm its result ("its supervisor reported no result within"), leaves the journal unchanged and exits 1.testStaleRecordAsideIsRemovedAndOthersAreLeftandtestUninstallRemovesRecordsAsideAndNamesWhatItCannotcover the removal rules.testEndThatCanNeitherRemoveNorRecordKeepsTheSleepEntryand the three nothing-recorded JournaledSessionEndTests now makemktempfail as well, so they still record nothing.testARejectedEndFloorBelowIntMinDoesNotTurnTheAgentsCutoffOff, the reviewer's hung-app case, with the app's own settings as the control. The two value tables gain rows for the texts above and name the agent's floor where it is higher than the app's (+30reads 30;4.9999999999999999,1e-400and-100000000000000000.5read 10). Every row also asserts that the agent's floor is never below the app's.PatchedBackstopgainsfailSudo(),refuseRecordsAside()andnamesAtSudo, the folder listing taken when the fakesudoruns.Round 21 verification
All local test commands skipped KeychainStoreTests, UIStatusTests and UIStartupTests. Every focused filter was anchored, its selected names were listed before the run, and the names that ran were checked against that list.
Configdecoder and plutil read each text from private files. Of 182 hand-written texts (integer, float, exponent, sign, JSON5 and boundary forms), 953b4b0 read 46 lower than the app and bf71148 reads none; neither does it on 62 more texts taken from the tests. 19 distinct texts read higher than the app (listed under "Not covered"). Of 26,885 random number texts, 18,676 were proven, none read lower than the app, 1,274 that the app accepts were not proven (the agent keeps at least 10%), and 6,935 the app rejects./bin/bash -n(3.2.57) on all seven scripts andshellcheck scripts/*.sh(0.10.0) passed.scripts/check-lid-simulation-gate.sh, which runsswift build -c release -Xswiftc -warnings-as-errorsfor the plain and lid-simulation builds, passed./usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with abash -cwrapper inside the lock that logged the time and inode before it ran thatswift testunder/usr/bin/scriptwithexec. It executed 1128 tests with 0 failures in 825 s, from 22:59:06 to 23:12:55 PDT, after waiting about nine minutes for another session's run to release the lock. The tests that ran are every listed test outside the three skipped classes, and the source fingerprints were the same before and after. The lock file was inode 201732085 before the run, when it took the lock, and after it ended, andpmset -g logshows no sleep or wake during the run.Hosted CI was not used to clear anything in this round.
Round 19: review of b96f62a
An independent review of b96f62a (GPT-6.1-Sol) returned NEEDS CHANGES with two findings. Both are fixed in 953b4b0.
Sources/Insomnia/Core/SessionManager.swift, reconcile step 2). With session.json, an unrelated ended-session.json and state.json all immutable, the agent restored sleep, keptsleepDisabledByUsand exited 1. Afterchflags nouchgon state.json alone, a relaunch wrote the journal and randisablesleep 1for the session the agent had ended. Reconcile now checks a journaled hold before it resumes: when the journal says Insomnia disabled sleep,pmset -gmust still reportSleepDisabled 1. Only an end clears that bit while the journal holds it, and no end can run beside reconcile, which holds the recovery lock. A 0 therefore means the hold was undone while no Insomnia ran: by the agent's unrecorded end, by hand, or by a start that died before itspmset. Reconcile then ends the session through the normal end, which records the end where it can (state.json, once that is writable) before it restores anything. A read that fails ends the session too. The journal write before every resume stays, so a journal that cannot be written still resumes nothing; that also covers an agent whose own restore failed and left the bit at 1, for as long as state.json stays unwritable. The agent's log line for this case now says the same.scripts/backstop.sh,config_int). Swift decodes anyIntand clamps the floor to 0...95, so Int.max is 95 and Int.min is 0.config_intread more than 18 digits as its default 10. With an immutable config.json holding Int.max, Start accepted the file, since both sides read it, and at 25% the app ended the session while the agent kept it.config_intnow takes a minimum and maximum and clamps as the app does across the wholeIntrange, with no shell arithmetic on a value past 18 digits. It compares 19 digits in two halves against 9223372036854775807 (…808 when negative). A float in plutil's exponent form is checked by its exponent, then by its 17 digits against 2^63. The app accepts a float when anIntholds its double, so -2^63 counts and 2^63 does not. A value the app rejects as out of range keeps the default, and so do fractions, strings and bools, as before. The exception is a text plutil rounds to -2^63 that the app still rejects (-9223372036854775809), which reads as 0; no session runs while the app rejects the file.Tests for round 19
testACutoffThatCanRecordNothingIsNotResumedOnceOnlyTheJournalCanBeWrittenlocks all three files, runs the real backstop.sh (exit 1, sleep restored, nothing recorded), unlocks state.json alone and relaunches. No session runs and nodisablesleep 1is sent; session.json stays and the journal now holds the end (endedSession) withsleepDisabledByUsclear. The next agent run exits 1 with "already ended" without reading the battery, and once session.json is unlocked the agent removes it and exits 0. A third launch stays idle.testACutoffThatCanRecordNothingIsNotResumedOnceEveryFileCanBeWrittenunlocks all three files: no resume, session.json removed, journal clean.testAValidSessionWhoseJournaledHoldWasUndoneIsEndedNotResumedandtestAValidSessionWhoseJournaledHoldCannotBeConfirmedIsEndedNotResumed(apmset -gthat fails) end the session with nodisablesleep 1.testTheAgentEnforcesTheEndFloorTheAppTakesFromAnyIntegerandtestTheAgentEnforcesTheEndFloorTheAppTakesFromAnyFloatwrite each value into config.json as raw JSON, take the floor the app'sStoredecodes, and run the real backstop.sh at one point below that floor (the session ends) and at the floor (it stays). The values cover 0, -0, 94 to 96, 18 and 19 digits, Int.max, Int.min, both just past each end, and floats in plutil's plain and exponent forms on both sides of 2^63. A value the app rejects must keep the agent's default.testAnEndFloorOfIntMaxThatCannotBeRewrittenIsNinetyFiveOnBothSidesandtestAnEndFloorOfIntMinThatCannotBeRewrittenIsOffOnBothSidesport the reviewer's immutable-file case for each end, then save the normalized config and run the same agent at the same reading as the control.LidActionsTests.testADeviceChangeBeforeTheLaunchReconcileWaitsForTheLidOfTheSessionOnDisk,ReconcileTests.testAStaleEndRecordDoesNotEndTheSessionOnDiskandJournaledSessionEndTests.testARecordOfAnEarlierSessionDoesNotEndANewerOne. Exact call lists in six tests (seven assertions) gain thepmset -gread before the resume'sdisablesleep 1(testValidSessionIsReappliedAndRearmed,testAStaleEndRecordDoesNotEndTheSessionOnDisk,testLidClosedKeepsFrozenPids, and three StillRunningCommandTests). No assertion was removed.Round 19 verification
All local test commands skipped KeychainStoreTests, UIStatusTests and UIStartupTests. Every focused filter was anchored, its selected names were listed before the run, and the names that ran were checked against that list.
disablesleep 1and resumed), both new ReconcileTests, andtestValidSessionIsReappliedAndRearmed, which now expects thepmset -gread.pmset -g logshows no sleep or wake in that window. Run again under/usr/bin/script, so the log streams, the first group stopped atAppNapTests.testJournalWriteFailureMeansNoPreferenceWrite, and AppNapTests run first, so the earlier run most likely waited there too. The test's fixture journaled the hold but left the fake's bit at 0, so reconcile ended the session and never reached thedisablesleep 1the test's gate waits for. The fixture corrections listed above followed./bin/bash -n(3.2.57) on all seven scripts andshellcheck scripts/*.sh(0.10.0) passed.scripts/check-lid-simulation-gate.sh, which runsswift build -c release -Xswiftc -warnings-as-errorsfor the plain and lid-simulation builds, passed at 953b4b0./usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with abash -cwrapper inside the lock that logged the time and inode before it ran thatswift testunder/usr/bin/scriptwithexec, so the log streamed. It executed 1114 tests with 0 failures in 783 s, from 20:57:22 to 21:10:28 PDT. The tests that ran are every listed test outside the three skipped classes. The lock file was inode 201732085 before the run, when it took the lock, and after it ended, andpmset -g logshows no sleep or wake during the run.Hosted CI was not used to clear anything in this round.
Round 17: review of de6131d
Codex (gpt-6.1-sol, xhigh) reviewed de6131d and found four code issues and one doc issue. All are fixed. Main took #50 during the round, and the branch merged it. Hosted CI then failed two tests on 7bab185, and a local full run found a third. All three are fixed in test code (see "Hosted CI on 7bab185").
Sources/Insomnia/Core/SessionManager.swift:659). Fixed in 98dba00.checkConfigFileaccepted a missing or valid file without loading its cutoffs. Three paths left the app on one end floor or thermal rule and the agent on another. They were a valid config.json deleted during a session, a Settings change to a cutoff whose save failed, and a rejected file repaired by hand with other values. The fix is the policy under "One set of cutoffs" above. Every transaction and the lid-open tick now take the file's cutoffs into the app, write a missing file back, and stop sessions when that write fails and the cutoffs differ from the agent's defaults. Settings saves a cutoff change before it applies it.initno longer writes defaults where config.json is missing, because it runs before the launch gate. The first transaction after the gate writes the settings in use. Migration markers, lid-close settings, duration settings and other user choices are kept. This closes the two items "Not covered" listed for a deleted file and a failed save.Sources/Insomnia/Core/SessionManager.swift:464). Fixed in 9a24156.SessionManager.initregistered the CoreAudio device callback, so a copy waiting at the gate, or refused there, restored a reconnected headset the owner had muted and wrote that change to the owner's journal.watchOutputDevices()now registers the callback once, andLaunchGatecalls it right after taking the alive lock, before start and reconcile. Lid close: leave meeting apps running, mute by default, update old configs once #49's restore path is unchanged, with its fresh locked reconnect, saved UIDs, closed-lid wait, retained entries and retries.scripts/backstop.sh:950). Fixed in 7cede36. With session.json and an unrelated ended-session.json both immutable and state.json writable, the agent restored sleep and kept onlysleepDisabledByUs, and the next launch disabled sleep again for the session it had ended. The end is now recorded in the journal (endedSession) before anything is undone, as described under "Ending a valid session" above. A stale record cannot end a newer session, because it matches only the exact bytes it was taken from, and the app removes it before it writes a new session.json and after it removes one. For the case where state.json cannot be written either, reconcile now writes the journal before every resume, not only whensleepDisabledByUsis clear, so the write that fails stops the resume.Sources/Insomnia/Core/SessionManager.swift:732). Fixed in 9e86194. Only a busy lock set the retry delay. The tick now waitsrecoveryRetryDelayafter any refused transaction (busy lock, unreadable journal, a command holding the lock), and the first tick after the delay adopts the agent's end once the journal reads again.docs/assets/recovery-flow.svg:3). Fixed in 7bab185. The description now names the three early ends and says the backstop reads its cutoffs from the same settings file as the app. The README's config paragraph, its recovery text, and spec sections 6, 8 and 10 describe F1's policy and F3's journal record. docs/release-validation.md gains three hardware rows for them, all Not run.Merging #50
f14c6ca merges main at bfc9a57, which adds #50. In
run_boundedthe conflict was resolved with main's version in full. The supervisor owns the command's limit and its SIGTERM by jobspec, waits run on theSECONDSclock, statuses are exit, term or alive with 125 for a missing status, and cleanup runs only under the run's own lock. This branch's earlier"$KILL"edit inside the oldrun_boundedwent with it. Both sides' test fixture steps are kept.b72a4f6 then rewrites this branch's
run_readto match #50's rules. The read no longer has a supervisor that writes its pid to a file for the caller to signal through$KILL. It is the shell's own job with fd 9 closed, bounded bywait_for_jobon theSECONDSclock, andsignal_jobsends SIGTERM and then SIGKILL by jobspec after checking that the job is the read. Its output goes through a.backstop.<pid>.<call>.outfile that the call removes. The first bounded call of a run removes leftover.pid,.rcand.outfiles only when the run took the lock on its own handle, never under a shared lock (remove_stale_run_files).Hosted CI on 7bab185
The hosted run on 7bab185 executed 1,174 tests, skipped 10, and failed two. Neither is a code regression. Both are fixed in test code, as is a third failure the local full run found next. No assertion was weakened and no wait was made longer. The backstop and #50's supervisor rules (SIGTERM only, the lock held until the command is reaped, the settled status) are unchanged.
AppNapTests.testJournalWriteFailureMeansNoPreferenceWrite, which failed locally too, is F3's new refusal at work. The test made state.json immutable before reconcile and expected a resume with only the App Nap entry refused. Since F3, reconcile writes the journal before every resume, so that write fails first and nothing resumes. be4b4c8 makes the file immutable atdisablesleep 1, after the resume's journal write, so the App Nap write is the first to fail, and the test still checks that the session runs, no preference is written and the error says why. A new test,testUnwritableJournalAtReconcileResumesNothingAndWritesNoPreference, keeps the old setup and checks that the refused resume holds no sleep and writes no preference. With the reconcile write made conditional again, as before F3, it fails three assertions.RecoveryScriptTests.testTheSupervisorOutlivesItsRunAndGroupSignalsAndHoldsTheLockUntilItReapsTheCommand, Backstop: the supervisor owns each undo command's limit and signal #50's test, is a race in the fake sudo, which is the same on main bfc9a57. The fake wrote its pid, the test's cue to signal the process group, before it randate +%sfor its 60 s watchdog. A group signal that killed thatdateleft the deadline at 60, so the fake exited 0 at once with "watchdog", and the supervisor saw "exit 0" instead of a live command. e52ea07 sets the deadline before the pid. With thatdateslowed by 0.5 s, the old order failed CI's seven assertions with CI's calls and status in two runs of two (25.9 s, against 25.5 s on CI), and the new order passed three of three.testALiveCommandIsReportedOnTimeWhenEveryPollIsSlowfailed in the local full run at be4b4c8 with two "sudo SIGTERM" lines, though Backstop: the supervisor owns each undo command's limit and signal #50's supervisor sends one SIGTERM, by jobspec, to the command's pid alone. The same fake ran$(date +%s)in its wait loop. Bash 3.2 starts a command substitution with the shell's pending traps and trap commands, and the child runs pending traps before its first command, so a SIGTERM that lands just before that fork is logged twice. b96f62a runs the watchdog on bash'sSECONDS, so the wait forks nothing but/bin/sleepand a group signal finds nodateto kill. A standalone bash 3.2 script ran one SIGTERM's trap in the child (BASH_SUBSHELL 1) and then the shell in three runs of three, and once withSECONDS. With a 0.2 s command substitution added before the time check, the old loop failed the test in three runs of four and the new loop passed six of six.Round 17 verification
All local test commands skipped KeychainStoreTests, UIStatusTests and UIStartupTests, and every focused filter was anchored with its selected names listed before the run.
testHungPowerCommandTimesOutReleasesLockAndKeepsJournalDirty, the battery, thermal, config, read and lock tests, the journal-shape tests, and the end-record tests. All 228 passed (177 s), and the tests that ran match the selected list exactly.FloorRulesand the agent reach the same decision, covering a deleted file, a failed battery save, a failed thermal save, a memory-only change and a hand repair. LaunchGateTests (F2) fire device changes while a copy waits at the gate and after it is refused. The headset stays muted and the journal stays byte-identical, and the copy that takes the lock restores the headset. JournaledSessionEndTests (F3) port the reviewer's case and follow it through a relaunch, later agent runs, unlocking the file and the next start. They also cover the app's own end, the running app's tick, a record of an earlier session, and a journal that cannot be written. New RecoveryScriptTests check that the record is written before the undo, written into a missing journal, not honored for another session, and rejected when it is not a string. ReconcileTests (F4) make three ticks inside the retry delay while the journal is unreadable. Only the first is refused and nothing changes. A repair inside the delay waits, and the first tick at the delay ends the session.initfailed. Forrun_read, signalling the read by pid through$KILLfailed. For F3 there were nine mutations. They were no journal record in the shell, the shell ignoring the record, no shape check, and in the app reconcile, the resume write, start, the tick, the end and the adoption each ignoring or skipping the record. For F4, a tick that waits only after a busy lock failed the unreadable-journal tick test at three assertions, and the other 47 ReconcileTests passed./bin/bash -n(3.2.57) on all seven scripts,shellcheck scripts/*.sh(0.10.0) andxmllinton the SVG passed at 7bab185, and the scripts have not changed since.scripts/check-lid-simulation-gate.sh, which runsswift build -c release -Xswiftc -warnings-as-errorsfor the plain and lid-simulation builds, passed at 7bab185, be4b4c8 and b96f62a./usr/bin/lockf /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, the command in use when it started, without-k. It executed 1105 tests with one failing test,testJournalWriteFailureMeansNoPreferenceWrite(3 assertions), fixed in be4b4c8. The lock file's inode was 201559993 when the run queued at 16:48 PDT behind another session's run. That run's lockf removed the file when it ended at 16:54:02, and the path then held 201609611. This run's tests started at 16:54:06. When it exited at 17:06:20 its lockf removed the file too, and the next waiting session's lockf created 201667978./usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with abash -cwrapper inside the lock that logged the time and inode before it ran thatswift testwithexec. It executed 1106 tests with one failure, the double-logged SIGTERM fixed in b96f62a. The lock file was missing when the run started at 17:40:23 PDT, because another session's run before it used lockf without-kand its exit at 17:36:15 removed the file. This run's lockf created it as inode 201732085 and left it in place at the end (17:52:41).lockf -kcommand andbash -cwrapper. It executed 1106 tests with 0 failures in 755 s, from 18:02:00 to 18:14:36 PDT. The lock file was inode 201732085 before the run, when it took the lock, and after it ended. No UIStatusTests, UIStartupTests or KeychainStoreTests case ran, andpmset -g logshows no sleep or wake during the run.Hosted CI was not used to clear anything in this round.
Decisions
AppAliveLocktype rather thanRecoveryLock: this lock is held for the process lifetime, needs 0600, and another PR touchesRecoveryLockpermissions.libkern/OSThermalNotification.h: 0 nominal, 1 moderate, 2 heavy, 3 trapping, 4 sleeping, andProcessInfo.ThermalState.criticalstarts at trapping, which is whereFloorRulesends the session.configVersionis migrated. In it, exactly the old defaults (30 days, the stock 8-preset list) read as the current ones and other values are kept. When the ceiling was not set by hand, presets and a default above 24 hours follow it down instead of the ceiling staying at 30 days for anyone whose default was 3 days. A file written by hand without the marker that sets exactly 30 days still reads as an old default; once the app has written the file, it carries the marker.pmset -g battwith the floor on still ends the session, and so does a failingioregafter pmset showed no battery row. A read that cannot show there is no battery is not proof of a desktop.ioreg, unprivileged and read-only. It asks for the AppleSmartBattery service the app checks, so the app and the backstop agree on what a desktop is.endFlooris read from plutil's XML output. The raw form rounds to six places and would read 30.0000001 as 30, a value the app refuses. (Until round 23, which removed the shell reader.)sudo pmsetcould.ended-session.json, first. The journal keeps its shape, uninstall.sh and older scripts read it unchanged, and the 1 Hz tick checks one small file. The record is a byte copy, so it matches only the file it copied and can never end a later session.endedSession, the same bytes in base64). The journal is the one file the app must write before it resumes a session, so a journal that cannot take the record cannot let that session resume either. Base64 of the exact bytes keeps the byte-copy rule, and/usr/bin/base64prints the same line as Swift'sbase64EncodedString().SleepDisabled. A 1 there can come from a restore that failed or from another program's hold, so it cannot show that a session is still live. A new file needs only a folder that takes one, which was the reviewer's case. The name shape is fixed (ended-session.json.and eight letters or digits) so the stale check and uninstall touch only files of that shape, and only a regular file with session.json's exact bytes counts, so a stale or planted copy cannot end a newer session.cmpreports other bytes. When session.json cannot be read or is not a regular file, the record stays, so a read failure cannot delete the only record of an end. The older rule for ended-session.json is unchanged: it is also removed whencmpcannot read session.json.endFloor, a text the shell cannot prove reads as at least the default, not as exactly the default. A higher floor ends a session sooner, so keeping plutil's reading when it is above 10% (+30reads 30) errs toward ending the session. (Until round 23, which removed the shell reader.)SleepDisabled 1, and nothing promises that a reboot ends one, so ending it would add a behavior, not fix one. Whether that bit survives a reboot was not measured, so the docs say the session resumes only if it does. The login ordering claim came from this PR's own spec edit. With launch at login off, the agent still ends the session at login.performEndis the one place that settles a pending end, because every end that restores runs it:end(), the adoption of an agent's end, and reconcile. Settling only inend()is what left the pending end behind after an adoption.recoveryRetryDelay, the delay the app already uses to retry an end that found the lock held. Every refusal it covers (a held lock, an unreadable journal, a command still running) lasts until something outside the tick changes.sessionCutoffs, and the defaults only when it records none.) The check runs in every transaction, not only at launch, so a file that turns bad during a session is caught at the next transaction, and a fixed or deleted file lets the next Start go ahead without a relaunch.chmodthere does not reach it, and the app and the agent already write to it. No other folder is searched, and only a real folder owned by this user counts.SessionManager.now. The controller used the wall clock while the manager and tests inject a fake, and the existing refused-extend test failed on that gap.maxDurationstill works; the refusal happens at commit.Not covered
kill -STOP, thermal end via an injected reading, a reboot with an active session with launch at login off and on, and, from round 17, a hand edit of a cutoff during a session, a Settings cutoff change while config.json is locked, an agent end with session.json and an unrelated ended-session.json both locked, and, from round 19, the same end with state.json locked as well, followed by a relaunch once state.json alone and then all three are unlocked (since round 21 that row expects a record aside and no resume, also withSleepDisabled 1), and, from round 21, the same end in a folder that takes no new file and a rejectedendFloorof -9223372036854775809 with the app stopped below 10%, and, from round 23, that end with the record in the log folder, the same with the log folder refusing too and session.json locked, a crash with session.json locked, duplicate, escaped and roundedendFloortexts, and--agent-cutoffsrun by hand on an installed app. They need supervised runs on a real Mac and cannot be done in CI..app.alivewith the default mode; the script's umask is handled in another PR.docs/assets/recovery-flow.svgstill shows the backstop as a restore path only. Its description, the README's alt text and the caption describe the early ends; the boxes were not redrawn.pmset -g battandnotifyutilonce a minute, and since round 23 also the app binary's--agent-cutoffswhen config.json exists.pmset -g batt,ioregwhen needed,notifyutiland, since round 23,--agent-cutoffs) have a time limit. plutil, ps and sysctl in the backstop still run in the foreground with the lock, as on main.sleepDisabledByUsand exits 1 every minute. Since round 21 this needs a folder that takes no new file (chmod 555on it, for example) as well as session.json, ended-session.json and state.json that cannot be written. The run's status files cannot be created either, so it cannot confirm whatpmsetdid: after its 37 s wait it logs that the supervisor reported no result, keeps the journal and exits 1. A relaunch resumes nothing while state.json cannot be written. Once the folder and state.json take writes again, an app launched before the next agent run readspmset -g. A 0 ends the session. A 1 (the restore failed, or another program holdsdisablesleep 1) resumes it as after a crash, with both sides' cutoffs in force again. Since round 23 the record goes to the log folder when the Application Support folder takes no new file, so nothing is recorded only when the log folder refuses too, and a relaunch also ends the session while session.json cannot be replaced. Once session.json, both folders and state.json take writes again, an app launched before the next agent run withSleepDisabled 1still resumes it: nothing on disk tells that end from a crash.testAllRefusedFullRepairResumesTheDocumentedLimitin the round 23 probes records this. Closing it would take a durable record of each session that survives every write refusal, or refusing every crash resume, and neither is in this PR. The replace check has its own cost: a session the app was running when it crashed is ended at the next launch, not resumed, while its session.json cannot be replaced. (Update in round 25: the record now goes to the recovery lock file when neither folder takes a new file, which closes the reviewer's full-repair case for a failed restore and for a later hold alike. Nothing is recorded only when the lock file also takes no write: it is not a regular file this user owns, or the write fails, as on a full disk. In that condition the relaunch after a full repair still cannot tell the end from a crash, as described above.)pmset disablesleep 0run by hand while Insomnia was stopped, or a start that died between its journal write and itspmset. Apmset -gthat fails at that point, or prints noSleepDisabledline, ends the session too.endedSessionthat matches nothing stays until the app's next Start or its next removal of session.json, and ends nothing. A record aside whose session.json is gone and that cannot be removed also stays; the backstop logs it on every run and uninstall names it.SleepDisabled 1after the reboot. Whether the bit survives a reboot was not checked on hardware; if it does not, the app ends such a session at login instead of resuming it.endFloorlimits, as of round 21; round 23 removed the shell reader, so none of these remain. The agent read these texts higher than the app:+30(plutil reads 30, the app rejects the file), values that are not whole but that the decoder rounds (4.9999999999999999is 5 to the app and 10 to the agent),1e-400, exponents longer than two digits, files past the tokenizer's limits, and files with a duplicate or escaped top-level key or a trailing comma. In none of these does the agent read a lower floor than the app or its default. Two limits predate round 21 and are unchanged: a file the app rejects because of another key still gives the agent that file'sendFloor, which can be lower than a hung app's floor, and a file plutil cannot read gives the default. The check adds about 4 to 7 ms to a battery check on a typical config.json and up to about 0.25 s on one at the tokenizer's limits.InsomniaAgentCutoffsVersion, gives no answer within 30 s, gets more than 8 MiB, or prints anything else, the agent enforces a 95% end floor with thermal rules on until it answers again. The agent runs only the script sealed in~/Applications/Insomnia.appafter checking that bundle's signature, so a missing binary or another version needs the bundle removed or replaced during a run. A config.json removed between the check and the open gives the strictest values for that run. A file the decoder rejects gives the agent the defaults (10%, on); with the app hung on a higher floor, the agent can end later than the app would. Start and reconcile refuse to run while such a file stays, so this needs the file to turn bad during a session. (Correction in round 25: a rejected or missing file gives the cutoffs the journal records for the session, so a hung app's floor is kept. The defaults remain only for a session whose journal records none, which a session an older build started. A journal value the app does not write, or a state.json the agent cannot read, gives the strictest values.) Each agent run with a valid session, the app alive and config.json present starts the binary once. (Correction in round 27: a state.json the agent cannot read now stops the run with the session kept, and when the binary cannot answer the agent reads the journal's record itself, so the strictest values remain only for a record the app does not write, a state.json that is a symlink to nothing, or no record while config.json is there.)pmset -a disablesleep 1, and SleepGuard still runs it withsudo -n. PR Sudoers: drop passwordless disablesleep 1; Start asks for the administrator password #32 owns that change, and the whole launch goal depends on it. This PR keeps the current privilege behavior.aliveLock.isHeld, and a release-validation row covers a second copy on hardware.SessionManager.initbefore the gate. It loads config.json and can write it back (a floor correction, the version marker, an undecodable file moved aside), but it writes no defaults over a missing file and registers no audio callback. The floor correction clamps as the agent does, so it leaves the cutoffs the agent enforces as they were.endFloorandthermalRulesuntil then, as for any hand edit. Since round 23 that holds for a file the decoder accepts; for one it rejects, the agent enforces the defaults. (Since round 25, the cutoffs the journal records for the session.)Data(contentsOf:)on macOS 26 refuses a FIFO without opening it. The check names the real cause and does not rely on that.FileHandle(forWritingTo:), which blocks on a FIFO with no reader (checked on macOS 26).Log.appendis unchanged from main, and this PR does not change it.sessionCutoffsvalue the app does not write or a state.json the agent cannot read gives the strictest values (95%, on), which ends a session with the floor off below 95% and applies thermal rules the user turned off. (Correction in round 27: a state.json the agent cannot read now stops the run with the session kept.) A session started by a build older than round 25 has no record and gets the defaults until the app's next transaction or tick records one. While config.json is missing or rejected, each agent run with a valid session and the app alive starts the binary once more, for the journal.1.0, a bad escape or number under a key the app does not read, NUL bytes, and UTF-16 (which Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's check passed when the file had no kept display records). For such a journal, and for any journal the app does not load, the agent keeps a valid session with sleep held, undoes nothing and exits 1 with a log line every minute,--forceincluded, and uninstall removes nothing, until the app or a person fixes the file. An expired session over such a journal stays, as before. When the binary cannot answer, the agent enforces the journal's record, which is what the app last recorded, so a hand edit of config.json that the app has not taken in yet is not seen then. A record the app does not write still gives 95% with thermal rules on, and so does a journal with no record while config.json is there and only the binary failed on it. A Settings change to a cutoff whose config.json save fails, while its record cannot be put back either, ends the session. Every agent run still removes or empties a stale end record (ended-session.json, a record aside, the lock file's) before it checks the journal. It touches only a record shown to match no session.json, so the check cannot lose a live session's end. Besidessleep, uninstall.sh takes two tools by name, on lines main's tests compare word for word with install.sh:dirname, which finds the script's own folder, and onecatinbounded(), on a line only a boundedsudoreaches, and uninstall.sh bounds none. The round 25 limits stay as they were, including lock file content that is not one whole record, which ends whatever session.json holds.sleepDisabledByUsandsessionCutoffs, and pmset readsSleepDisabled 1. In the second, the agent ends the session for a cutoff, can record that nowhere, restores sleep and keepssleepDisabledByUs; another program then setsdisablesleep 1, and a person repairs every file. A relaunch reads the same session.json bytes, the same journal andSleepDisabled 1in both, so it resumes both. The log~/Library/Logs/Insomnia/insomnia.logcan still take an appended line in some of these cases, as the round 25 review showed, so this is not a case where no file could hold the record. A record in the log was assessed and not built. Every reader (the app's reconcile, tick and transactions, the agent and uninstall) would have to scan the log and its.1copy, rotation at 1 MiB drops a record after two turns, a line a person writes would end a session, it does not help on a full disk, and a record cannot be removed without rewriting the log. The options are that log record with those costs, refusing to resume this case, which would also stop the crash resumes the spec keeps because the files are the same, or accepting the limit. This round chose none of them. A boot identifier, a helper and new privileges are out of scope.swift test1200 s after it starts it, and that time includes the test build. On bec766b it stopped a run after 1047 cases, all passed, with the 1048th still running and later cases not started. 1e1178b's shorter fixture limits save about 129 s locally, but the merged head also runs Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's 145 cases and this round's 8. Scaled by the hosted/local ratio on bec766b, the hosted run needs about 1377 s of test time, or 1470 to 1490 s with the build, so it will likely be stopped again. That is a projection from local runs and two hosted logs, not a hosted run on this head. The choices are a longer watchdog, splitting the suite across jobs, or more cuts to slow fixtures. The first two change the workflow, which this round did not do..1cannot be read, or while session.json cannot be read and.1holds any record.sessionCutoffsvalue the app does not write, state.json a symlink to nothing) the agent still enforces 95% with thermal rules on and can end a session the app keeps. The alternatives are listed under item 4 for the parent; this round chose none of them.Store.lockRecordWriteLimitForTestingis a DEBUG-only seam that release builds compile out. install.sh still finds its folder with a baredirname, main's line. No test covers the start refusal while the lock file cannot be read, and no release-validation row covers the log record.RecoveryScriptTests/testUninstallUsesTheCheckoutBackstopWhenTheAppDeclaresTheVersion, about 6 s after it started. The release and lid steps were skipped, and the workflow file is main's. Item 5's tables took 100.9 s there against 275.4 s on 47bdc6c, but the 1100 other cases both runs finished took 9.8% longer. The catalog at f2298fe projects to about 1291 s of hosted test time against about 1178.5 s, so one job will be stopped again. The six slowest serial tables left take 94 s hosted; at item 5's rate, running their rows at once would save about 59 s, which is not enough, so they were not changed. Most other slow cases wait out command limits on purpose. A two-job split or a longer watchdog stays the parent's choice.sessionCutoffs), a whole number written with a fraction or an exponent that aDoubledoes not hold exactly (9007199254740993.0), a number aDoublerounds (1.0000000000000001,1e-99999), a number or escape plutil cannot parse even where the app skips it (01,1e400,\a, an escaped NUL character, a lone surrogate) and a NUL byte. For those journals round 29's line still holds. UTF-32LE with a byte order mark is refused by the app too.sessionCutoffsvalue the app does not write and a state.json that is a symlink to nothing now count as no record, and with no record the defaults (10%, on) apply while config.json is missing or rejected. 95% with thermal rules on remains only while config.json is there, neither the binary nor the agent can read it, and the journal holds no record the app writes. With no record, the defaults can keep a session below the end floor a hung app enforces. Neither stopgap is approved ("Choices for the parent" under round 31).Store.lockReadErrnoForTesting, which release builds compile out.AGENT_PROGRAMline (the LaunchAgent's refusal line) now differs from main's.sessionCutoffswritten twice: the binary reads the first copy, as the app does, while the agent's own reader, used when the binary cannot answer, takes it as a record the app does not write.>and its write. No test covers a failed cleanup specific to the log tail rule.RecoveryScriptTests/testUninstallStopsAHungCallOnTimeWhenEveryPollIsSlow, 3.9 s after it started, and the cases it finished took 1202.2 s. The release and lid steps were skipped, and the workflow file is main's. Round 33 runs four slow tables row by row on separate fixtures and changed no workflow, watchdog or timeout. Whether one job now finishes in time is not known until hosted CI on 62d57c5 reports. "Hosted CI on f2298fe passed" above stays as written.Earlier rounds
The notes below describe each earlier round as written at the time. "What" above describes the branch now.
Docs: README battery rules and "How recovery works" (illustration alt text and a caption too), spec sections 1, 6, 8 and 11 plus manual test plan item 4, SECURITY.md (any same-user process can hold the lock), the backstop.sh header, and four new "Not run" rows in docs/release-validation.md. Every existing row stays "Not run".
Review follow-ups (second commit):
EndReason.agentCutoff), retrying anything the agent left and stopping observers and timers. The 1 Hz countdown tick does the same check with a stat first, so an open-lid session is dropped within about a second.endFloorandthermalRuleswithplutil -type, so a string "30" or "false" falls back to the default as it does in the app. It readsendFloorbeforepmset, so with the floor at 0 nothing is read and a failing pmset cannot end a session. Both reads have the undo commands' time limit: a hung battery read ends, a hung thermal read warns. Since the Codex round they also run without the lock (see below).Second review round:
configVersion2 and only a file without it is migrated, so a 30-day ceiling set by hand is kept. The app writes an older file back once at launch so a later hand edit of it counts too.Merged main (#29, #31, #16, #37) with a merge commit. The README battery rules paragraph conflicted with #29: it keeps #29's unreadable-battery end in the app and this PR's sentence that the ends run without the app. Spec section 6 said the backstop enforces "the two ends"; with #29 there are three, and the backstop ends on the first
pmsetread it cannot use where the app tolerates one IOKit miss. Main was merged again for #26 with no conflicts.Main was merged a third time in 87fe970 for #44, #24, #35, #42, #27 and #45, and a fourth time in d46a6fe for #46, #36, #23 and #17, each with a merge commit. Each conflict was resolved so both sides keep their behavior, and each merge commit's message lists the resolutions file by file. #27's sentence in the
.greptile/config.jsonfixed-path rule (a script may stop its own child with the builtinkill) merged without a conflict and is unchanged.Two follow-ups bring this PR's code in line with main. e8c749d routes the six bare
rmandmvcalls this PR added to backstop.sh through$RMand$MV. a676549 has the app open ended-session.json and session.json for the end record only when they are regular files, as #23'sStore.readdoes.Main was merged a fifth time in cadfe0c for #21, with no conflicts. Its new
tmuxNudgePressesEnterkey decodes like the others. With #36 every test run has a temporaryINSOMNIA_HOME; this PR's tests build their paths fromTempHomeor the script fixture, never the real home folder.Main was merged four more times after 91e6d3c, each with a merge commit that keeps both sides:
Store's file read keeps this PR's raw-data form, which the config and end-record checks use, and runs Store: owner-only files and directories, logs capped at 1 MiB with one rotation #19'sOwnerOnly.tightenfirst. backstop.sh keepsNOTIFYUTIL,IOREGandCMPbeside Store: owner-only files and directories, logs capped at 1 MiB with one rotation #19'sCHMOD, andrun_readbeside main'srun_app_bounded. uninstall.sh's purge removes the end record and Store: owner-only files and directories, logs capped at 1 MiB with one rotation #19's rotated logs, and its moved-aside loop covers both session.json and config.json copies with main's message for a non-regular file. RecoveryScriptTests' fixture patches the tools from both sides, the purge test expects the end record and the rotated logs gone, andsetCommandTimeout,holdAliveLock,insomniaTableandspawnare all kept. Store: owner-only files and directories, logs capped at 1 MiB with one rotation #19'stestBackstopTightensWhatAnOlderBuildLeftLoosechecks session.json's mode after a run with no app, and on this branch that run ends the session, so de6131d holds the alive lock for it.dockerRuletest.SessionManager.exclusivetakes Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22'sowesargument,RecoveryLock.$heldhandle and.commandRunningrefusal, and keeps this PR'ssyncSessionflag. Under the lock it runs main'swriteOwedEdits(), then this PR's agent-end adoption, config check and rejected-config end, then the operation. If that prologue's own end leaves a privileged command running, the operation is refused with.commandRunningand the lock is held until the command exits, as Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22 does for any transaction.end()passesowes: .end(reason)andsyncSession: false. An unreadable journal keeps this PR's end retry, and Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22's.privilegedCommandRunningoutcome is handled. In reconcile, the agent's end record, a rejected config.json and an expired session each end throughperformEndand stop reconcile while Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22 reports a command still running. Lid close: leave meeting apps running, mute by default, update old configs once #49's saved audio outputs and itsowesEndpath are unchanged, and so are Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22'sreconcile(ticket:isRetry:), earlier-command check and reconcile retry.agentCutofftext.ConfigkeepsconfigVersionand Lid close: leave meeting apps running, mute by default, update old configs once #49'slidCloseDefaultsAppliedandlidCloseDefaultsNotice.PathsandStorekeep the alive lock file,configHasVersionand Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22's unfinished-command file.AppDelegatekeeps Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22's paragraph on sudo pmset and its quit deferral for a running command, and this PR's guard that quits a copy without the alive lock at once.unfinished-command.json, with and without--purge.aliveandholdAliveLockand Backstop: run only the copy sealed in the signed bundle #28's bundleinstalledBackstop,legacyBackstopandrequirement, with Sleep guard: never SIGKILL sudo pmset; keep the lock while it runs #22'shungPid,slowPollingPathandrunTool. ConfigTests keeps this PR's duration tests and Lid close: leave meeting apps running, mute by default, update old configs once #49's lid-close tests.testPassWaitingForTheJournalLeavesAPendingEndTheCleanupexpected nothing to run once the journal was fixed. This PR retries an end refused for an unreadable journal (9bbfdc9), where main leaves it pending, so de6131d waits for that retry and checks that it ran only the end's restore (disablesleep 0,lowpowermode 0), with no mode check and no floors.Config()was what an older file decodes to. With Lid close: leave meeting apps running, mute by default, update old configs once #49 such a file has no lid mark and mute off. 7a05ff6 adds the mark to the version-marker fixture, and 3e5dd12 compares the legacy-defaults case with an earlier build's lid settings.Earlier review rounds
Codex (gpt-6.1-sol, xhigh) reviewed 4e08846. All three findings are fixed.
scripts/backstop.sh:454). Fixed in eef24dc. A run that ends a valid session, by a cutoff or--force, now removes session.json under the lock right after the decision, before it reads the journal or undoes anything. What the undo cannot finish stays in state.json. The next run completes it with no session to check, and a relaunched app finds no session, so its reconcile restores instead of disabling sleep again. An app that is alive but stopped sees session.json gone and ends its side. Expired sessions keep their old handling. The end can now show while a hung undo command still holds the lock, so the app's 1 Hz tick waitsrecoveryRetryDelay(30 s) after a failed lock wait instead of starting a 10 s wait and a log line every second.scripts/backstop.sh:243). Fixed in 327682a. The battery and thermal reads run through a newrun_read. Its supervisor closes fd 9 before it starts the read, so neither the read, its children, nor the supervisor holds the lock. A read that ignores SIGTERM gets SIGKILL. Undo commands keeprun_boundedand hold the lock while they run, as before. fd 9 is the only descriptor the scripts open for recovery.Tests/InsomniaTests/RecoveryScriptTests.swift:2270). Fixed in 7707b3e.holdAliveLocktakes the lock withAppAliveLockin the test process, and each test releases it in itsdefer.holdLockis back to the body it has on main.884ff01 also rewords one spec line that quoted the duration tooltips with en dashes, which Greptile's latest summary flagged against the repo's writing rule.
Codex (gpt-6.1-sol, xhigh) and Greptile reviewed 884ff01. All findings are fixed.
scripts/backstop.sh:499, the same finding as Greptile comment 4168633620 at line 501). Fixed in b9cd044. Greptile'sexit 1was not used, because it skips the undo and leaves sleep disabled with the app gone. When the removal fails, the run now copies session.json's bytes toended-session.jsonand goes on with the undo. While the two files match, the app's reconcile restores the session instead of resuming it, the 1 Hz tick and the next transaction end a session the app still holds, and every later run ends it again without the checks and retries the removal. The record goes once session.json does, and a record that matches no session.json is removed. The app writes the same record when its own end cannot remove the file. If the record cannot be written either, the run still restores sleep, keepssleepDisabledByUsjournaled, and exits 1 with a log line that names the file.Tests/InsomniaTests/ReconcileTests.swift:471). Fixed in 242e4e9. The test now runs on the fixed harness clock, so the real 1 Hz timer never fires, and it callsnoticeAgentEnditself: one tick with the lock held, ticks within the delay after its release, and the tick at the delay that ends the session.$KILL(comment 4168633632,scripts/backstop.sh:272). Fixed in 5a9b68e. The SIGTERM and SIGKILL inrun_readand the SIGTERM inrun_bounded, bare since main, all go through"$KILL". The fixture's fake kill passes-TERMand-KILLon to/bin/killonly for a pid in one of the run's own.backstop.*.pidfiles.Codex (gpt-6.1-sol, xhigh) and Greptile reviewed 5a9b68e. All four findings are fixed.
Sources/Insomnia/Core/SessionManager.swift:322). Fixed in 9bbfdc9. An end refused for an unreadable journal setpendingEndwith no retry, and onlyend()cleared it. After the tick adopted the agent's end, every Start was refused.performEnd, whichend(), the adoption and reconcile all run, now settles the pending end when it restores or leaves the restore to an armed agent. It clearspendingEndand invalidates the retry timer, which is obsolete by then. An end refused for an unreadable journal now schedules the same retry as a held lock, so it ends the session once a person repairs the file.Tests/InsomniaTests/RecoveryScriptTests.swift:2266). Fixed in ecf7db4. The lsof check moved out of the SIGTERM test into its own fake mode,CHECK_FD9, which records what it found and then answers. Its test raises the command limit to 30 s and asserts that the read finished, so a slow lsof cannot fail a correct run.docs/spec.md:395). Fixed in cbe8cdf, in the docs. Reconcile resumes a valid session at every launch (spec step 2), and nothing in the spec, README or app requires a reboot to end one. Spec section 8, manual test item 4 and the release validation rows now describe the real outcome. With launch at login off, the agent ends the session at login. With it on, the app may resume the session first, and it then lasts until its deadline or a cutoff. No boot identifier was added (see Decisions).scripts/backstop.sh:537). Fixed in 42f369d. The backstop and the app'sStore.deleteSessionlog a record they cannot remove, naming the file.uninstall.shremoves the record after its backstop run, with or without--purge, and prints a "Kept" line naming it when it cannot. Since the d46a6fe merge it goes through Recovery: move an unreadable session.json aside instead of keeping it forever #23'sremove_owned, which names it on stderr and exits 1.Codex (gpt-6.1-sol, xhigh) and Greptile reviewed cbe8cdf. All four findings are fixed.
scripts/backstop.sh:501). Fixed in 3c302ba. Whenpmset -g batthas no InternalBattery line, the backstop now runsioreg -r -c AppleSmartBattery -d 1, the servicePowerMonitor.classifychecks. No service means a desktop and no battery rule, as before. A service with"ExternalConnected" = Yeskeeps the session. A service without a charger reported ends it as an unreadable battery, which is the app's rule. An ioreg that fails or hangs cannot show that there is no battery, so it ends the session, as a failing pmset does.scripts/backstop.sh:460). Fixed in 3c302ba.config_intnow accepts a float that is exactly an integer, as JSONDecoder does: 30.0 and 3e1 are 30, and 0.0 turns the rule off. It reads the value from plutil's XML form, which prints the shortest exact value, so 30.0000001 stays a float and falls back to 10. The raw form would round it to 30.000000. The floor is then clamped to 0 through 95, asConfig.normalizeFloorsdoes, so 200 is 95 and -5 is off.Sources/Insomnia/InsomniaApp.swift:84). Fixed in d245a97. A newLaunchGatetakes the alive lock before anything else runs. Only then does the launch run the login item check, the Settings window, the status item and reconcile. A copy that cannot take the lock within 2 s, or cannot open it, posts a notification saying why, waits until the system has it, and quits. Quitting such a copy ends nothing, because an end there would restore the journal of the copy that holds the lock. The every-2-s retry andAppAliveLock.acquireEventuallyare gone.Sources/Insomnia/Model/Config.swift:140). Fixed in 6d99b72.configVersioncounts by its presence, asStore.configHasVersionalready did, so"2"keeps every setting. The root cause was the launch writing defaults over any config.json that did not decode. Such a file is now renamed toconfig.json.unreadable-<UTC stamp>first, with Recovery: move an unreadable session.json aside instead of keeping it forever #23's helper, and the first reconcile posts a notification naming the copy. If the rename fails, nothing is written over the file.uninstall.shkeeps the copies and--purgeremoves them.Codex (gpt-6.1-sol, xhigh) reviewed cadfe0c. Both findings are fixed.
scripts/backstop.sh:512). Fixed in 784a080. The root cause was that the backstop opened files under the recovery lock without checking that they were regular files. Only session.json and state.json had that check. A newend_recordedcompares session.json with ended-session.json only when both are regular files. It replaces the compare at the start of the run and both compares inrecord_end. The app andrecord_endwrite the record by rename, so anything else at that path is stale and is removed withrm, which unlinks a FIFO without opening it. The same rule now covers the other files the backstop reads or writes under the lock. config.json is read only as a regular file, and anything else reads as a missing file with the default settings, as in the app'sStore.readData.log()appends only to a regular file and drops the line otherwise. The PID-named temp files inrecord_endand the state publish are removed before they are written. The alive lock probe needs no check, because lockf with-t 0fails at once on a FIFO (exit 73, checked on macOS 26).Tests/InsomniaTests/RecoveryScriptTests.swift:611and:739). Fixed in 12417c4. A newpointInsomniaHome(at:)in TestSupport moves the variable and returns a closure that puts back the value it had, orProcessTestHome.rootif it had none. Both tests call that closure in adefer.RecoveryScriptTests.tearDownnow checks that each test ends with the variable on the loader's home. If not, it puts the home back and fails the test. These two tests were the only ones that changed the process environment.TempHomealready restoresProcessTestHome.rootwhen destroyed, and the other INSOMNIA_HOME uses are child process environments (extraEnvironment) or a dictionary passed toPaths.fromEnvironment. No code in Tests or Sources callsunsetenvnow.Main took #47 and then #38 while this round ran, and the branch merged each. d76d4e5 merges #47. Its one conflict was in SessionManager's stored properties, where main's
keptSessionFilereplaced the two flags next to this branch'sconfigNotice. Both are kept. #47'stestSessionWithOffsetDatesIsReadLikeTheAppexpected the backstop to keep a future session with no app running. On this branch that session ends, so 4a13512 holds the alive lock for that run. 906b5d2 merges #38. Its one conflict was inexclusive, where main'swriteOwedEdits()now runs before this branch'sadoptAgentEnd(). So an adopted end never resumes a pid that an undone freeze already released and that may since have been reused.Codex (gpt-6.1-sol, xhigh) reviewed 906b5d2 and found the code correct, with one doc finding.
SECURITY.md:19). Fixed in bda4dce. The backstop still applies the battery end floor and the thermal cutoff while another process holds.app.alive; only the app-liveness check is lost. The paragraph now says so.Codex (gpt-6.1-sol, xhigh) reviewed bda4dce. The one finding is fixed.
scripts/backstop.sh:599). Fixed in 91e6d3c, on the app side as the maintainer asked; the shell still reads onlyendFloorandthermalRules. The root cause was that the app checked config.json only at launch and then kept running sessions on its defaults while a file it rejected stayed in place for the agent to read. Every transaction now applies the launch's rules again: a file that does not decode is renamed aside and the settings the app runs on are written in its place. While it cannot be renamed,rejectedConfigFileholds why and no session runs. Start refuses and changes nothing, with a notification that names the file and says to make it writable or delete it. A running session ends at the next transaction through the normal end, with the new reasonsettingsFileRejected. Reconcile ends a valid session on disk instead of resuming it. Making the file writable or deleting it lets the next Start go ahead without a relaunch. The spec (sections 6 and 10), README and release validation now say that the backstop reads the file's scalar keys directly, so the app refuses to run a session on a config it rejected.Codex (gpt-6.1-sol, xhigh) and Greptile reviewed 91e6d3c and made the same finding. It is fixed.
Sources/Insomnia/Core/SessionManager.swift:454, Greptile comment 4171920689). Fixed in 0c4a3b7, with a gap closed in 7a05ff6. After a rejected config.json was moved aside, the rejection cleared even when writing the settings in use in its place failed, on a full disk for example. Later transactions found no file and accepted that. backstop.sh then enforced its 10% default floor while the app enforced the user's 30%, and an app that was stopped or hung left the Mac awake below the chosen floor. In 0c4a3b7 the write stays owed until it succeeds. Every transaction tries it again while config.json is missing, and the rejection clears only once the file is on disk. Until then Start refuses, a running session ends through the normal end, and reconcile does not resume one. The notification says to free disk space or make the folder writable, and the move notice no longer claims the settings were written. Checking that fix found one more way to the same state: a rejected file that could not be moved, then deleted as the refusal tells the user to do, cleared the rejection with no write. In 7a05ff6 a failed rename owes the write too, so the next transaction writes the settings in use before a session can run, and sessions stay refused while that write fails.Tests in earlier rounds
This section condenses the earlier rounds' test lists to fit the body's length limit. Each commit message lists its tests in full.
pmset -g battandnotifyutil, cover the app alive and released, a clean journal with no app, battery 9% and 10% on battery and 3% or 0% on AC, unreadable battery output and a failing pmset, a desktop with no battery,endFloor30, 0 and bad values, thermal levels 0 to 4 with unreadable output andthermalRulesoff, the reason in the restore log line, and--forcerunning no probe. AppAliveLockTests cover a second holder, 0600, FD_CLOEXEC, release on deinit, the bounded acquire and the lockf probe seeing 75 then 0. Config, DurationInput, UIStatus and UIStartup tests cover the 24-hour default and the "Up to 1d" refusal.$KILLasserts in the timeout tests. Mutation checks covered the record, its use by each reader, the neither case, the barekillcalls and the tick delay.endFloorfloats, moved-aside config copies in uninstall, the version marker and the move-aside of an undecodable config.json (ConfigLoadTests), the launch gate (LaunchGateTests), and the FIFO end-record check (StoreTests). Mutation checks failed 1 to 26 tests each.end_recorded,log()and the home restore.Full suites, each as
/usr/bin/lockf /private/tmp/insomnia-fable/swifttest.lock swift testwith UIStatusTests and UIStartupTests skipped because they put real status items in the menu bar (hosted CI runs them). From de6131d on, KeychainStoreTests was skipped too.At 3e5dd12, #19's
testBackstopTightensWhatAnOlderBuildLeftLooseand #22'stestPassWaitingForTheJournalLeavesAPendingEndTheCleanupfailed for the reasons in the merge notes above. The first full run at de6131d did not finish.testInstallUnloadsTheNewJobBeforeRollingBackWhenItsLoadIsUnconfirmedtook 935 s and failed on its 5-second limit for the fakelaunchctl print, andpmset -g logshows the Mac in clamshell sleep from 14:29:53 to 14:45:24 PDT, the same window. That run then stopped with no exit record. The second run at de6131d, in the table, started after the Mac woke and passed that test in 3 s.From the de6131d round on, every local full run skips KeychainStoreTests along with the two menu bar suites, so it calls no real Security API. The runs in the tables above, at 91e6d3c and earlier, predate KeychainStoreTests on this branch; it arrived with #25 in 5e00cb2. Full runs made on this branch after that merge and before that round skipped only the two menu bar suites, so they also ran KeychainStoreTests' 14 tests, which use the real Security APIs on temporary keychains. None of those runs is recorded here. Hosted CI runs all three suites.
The CI checks that run on this Mac passed at 91e6d3c (the
/bin/bash3.2 syntax check and bash 4 grep,shellcheck scripts/*.sh,swift build -c release -Xswiftc -warnings-as-errors) and at de6131d (the release build andscripts/check-lid-simulation-gate.sh). actionlint and zizmor were not run here; this PR does not touch workflows.🤖 Generated with Claude Code
Fix the shell’s negative-fraction check before merging; it can turn off a recorded battery cutoff.
Fix with agent prompt
Summary
This PR adds backstop ends for an absent app, low battery, and critical heat. It also lowers the default session ceiling to 24 hours.
Prior acknowledgments:
krishhggaccepted ending on a failed battery read with the floor enabled because that failure cannot prove the Mac has no battery.krishhggdeferred redrawing the recovery illustration.krishhggretained migration of an unversioned hand-written 30-day limit because it cannot be distinguished from the old default.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Backstop takes recovery lock] --> B[Read journal as the app reads it] B --> C{Journal usable?} C -->|No| D[Keep files and report failure] C -->|Yes| E{Session valid?} E -->|Yes| F[Check app, battery, and heat] F -->|Pass| G[Keep session] F -->|End| H[Remove session or record its end] E -->|No| I[Undo journaled changes] H --> I I --> J[Keep failed undo entries for retry]Reviews (24) · Last reviewed commit: "Tests: the cut-short and bad-escape conf..." · Reviewed by Greptile