Skip to content

Backstop: end a valid session when the app is gone, the battery is below the end floor, or heat is critical - #34

Open
krishhgg wants to merge 71 commits into
mainfrom
fix/out-of-process-cutoff
Open

krishhgg wants to merge 71 commits into
mainfrom
fix/out-of-process-cutoff

Conversation

@krishhgg

@krishhgg krishhgg commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Why

Sleep stayed disabled until the journaled deadline whenever the app stopped running. The launchd agent restored the journal only once endsAt had 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) on APP_SUPPORT/.app.alive from launch until exit (AppAliveLock, O_CLOEXEC, created 0600, never unlinked). The kernel releases the lock however the process dies. LaunchGate takes 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.sh runs three checks before it lets a valid session stand, in this order. Each one that fails ends the session as --force would and logs the reason.

  • App alive: 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.
  • Battery: pmset -g batt. An internal battery present, 'Battery Power' as the source, and a percentage below endFloor (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 when ioreg finds no AppleSmartBattery service, which is the check PowerMonitor.classify makes.
  • Thermal: notifyutil -g com.apple.system.thermalpressurelevel. Level 3 (trapping) or above ends the session when thermalRules is 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. --force runs 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 the SECONDS clock, then gets SIGTERM and SIGKILL by jobspec. Undo commands keep #50's supervise_command and run_bounded unchanged.

Ending a valid session. A run checks that state.json loads as the app loads it before it ends a valid session, --force included. 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 in ended-session.json, a copy of its bytes. When that file cannot be written either, it records the end in the journal as endedSession, the same bytes in base64. When state.json cannot be written either, it writes the copy to a new file named ended-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 or cmp reports other bytes; while cmp cannot compare them they stay. When the lock file takes no write either, the run appends the end to ~/Library/Logs/Insomnia/insomnia.log as 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 one write(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) takes flock(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, keeps sleepDisabledByUs, 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 reads pmset -g, and a SleepDisabled of 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 with Store.decodeConfig, the decoder Store.loadConfig uses, prints the end floor clamped as normalizeFloors clamps 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 declares InsomniaAgentCutoffsVersion 3. A missing, non-regular or unreadable file, or one the decoder rejects, gives the cutoffs the journal records for the session, sessionCutoffs in state.json, which the same binary reads with the app's own reader (Insomnia --agent-session-cutoffs 33) after it decodes the whole journal as Store.loadState does. 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 answers rejected for 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 by record_text_problems in its config form without plutil, which also checks the type of every value the app's Config decoder reads. It then takes endFloor (10 when absent or null, clamped to 0 to 95) and thermalRules (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 reads sessionCutoffs from 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 writes sessionCutoffs before 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:

  • A file that decodes has its two cutoffs taken into the app. The Low Power Mode floor is raised above a higher end floor, as normalizeFloors does. No other setting changes.
  • A missing file gets the settings in use written back. If that write fails, no session starts or continues unless the app's cutoffs are the agent's defaults.
  • A file the app cannot decode is renamed to 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.maxDuration defaults 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 test after 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.

  • R32-1, a log record broken by another writer's line. Every writer of insomnia.log that Insomnia ships now takes 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's log and record_end_in_log take the lock with /usr/bin/lockf -s -t 5 on 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 with lockf -s -t 5, and reads the last byte, writes its newline and writes its line under that one lock. RecoveryLockHandle.replaceContents and OwnerOnly.writeAll now end the attempt when a write returns no byte, where they could otherwise retry forever.
  • R32-2, journals the app loads that the scripts refused. 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-400 as 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 a Double the 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.
  • R32-3, config.json the agent's own reader could not use. config_cutoffs now 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.
  • A lock file that cannot be read. Store.readLockFile and backstop.sh's read_lock_record try 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.
  • R30-1 and R30-3 are unchanged apart from the zero-byte write guard. R30-6 is unchanged, and nothing in this round narrows it.
  • Fixtures. Four slow tables whose rows are independent now run each row on a fixture or home of its own, eight at a time, through the existing ScriptFixture.runAll and SeparateRun.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.

  • OwnerOnlyTests: testAnAppendWaitsForTheLogsLockAndStartsAfterWhatItsHolderLeft, testALineThatGetsNoLockIsWrittenBeforeTheNextOne and testAWriteThatWritesNothingEndsTheAppend.
  • LogEndRecordTests: testTheAgentsWritesWaitForAWriterThatHoldsTheLog and testTheAgentWritesNothingToALogItCannotLockAndSaysSo.
  • LaunchdBackstopTests: testTheAgentsLineWaitsForEveryPieceOfAnAppLineCutShort and testTheAgentsLineFollowsARotationItWaitedFor. 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.
  • LockEndRecordTests: testAWriteInPlaceThatWritesNothingEndsTheAttempt, testTheAppReadsALockFileAgainBeforeItCountsAsTheEnd and testTheAgentReadsALockFileAgainBeforeItCountsAsTheEnd.
  • RecoveryScriptTests: testDuplicatedKeptDisplayRecordsAreReadAsTheAppReadsThem replaces testDuplicatedKeptDisplayRecordsAreRefusedByBothScripts, whose name stated the old rule. The acceptance and reader tables have new rows.
  • CutoffAgreementTests: the config tables cover the new reader (a config rejected for its end floor, cut short, with an escape JSON does not have, over 8 MiB, and with an Int64 on which Foundation stops the app).
  • Assertions that changed on purpose, from the audit of every removed or replaced assertion:
    • The acceptance table now requires both scripts to answer as the app does for every row. In nine rows they refused a journal the app loads, and now they must accept it.
    • In the reader table, 13 rows that expected a refusal now expect the app's reading: keys written twice, an escaped Kelvin sign, 9007199254740993.0, 1.0000000000000001 and 1e-99999. 53 more rows changed only their problem wording or view lines. All 88 rows keep their labels and app flags.
    • Three duplicate-key journals moved from the test of journals the app does not load to 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.
    • The 5100.5 case expects the line the scripts now write.
    • The configs cut short and with an escape JSON does not have moved from the test of configs read neither way (strictest) to the rejected-config test (the record, else the defaults). In 0b42d74 they ran there only with the binary missing, which dropped 12 combinations they ran before: a binary that declares another version, prints something else or does not answer, at two batteries, on the record. 62d57c5 restores those 12.
    • Fixture reads that fail now fail the test, and the structural checks on the LaunchAgent's program are stricter (one descriptor, the lock taken before the tail read).

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.

  • Local, on 62d57c5: 1160 cases (the 1154 the screen passes and the six above), run by exact test name under the shared lock with the three skips, all passed, exit 0 (967.3 s, 21:24:57Z to 21:41:06Z). This is not the mandated full command. The same 1160 also passed on 0b42d74, and the test 62d57c5 changes passed on its own first.
  • 224 cases were not run locally on this head. Among them are 92 RecoveryScriptTests, 8 LaunchdBackstopTests and 6 JournaledSessionEndTests cases, and round 31's test of a lock file that cannot be read, which changes an ACL. Hosted CI runs them.
  • Earlier runs this round, each by exact test name under the shared lock: focused-1 failed one case, 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.
  • ShellCheck 0.10.0, bash -n under /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-errors exited 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.
  • Cost: on six script-heavy cases with the same test binary, round 33's backstop.sh took 75.9 s and 75.0 s against 70.7 s for a41341c's, about 6.6% more.
  • Hosted CI and Greptile on 62d57c5 had not reported when this was written. The hardware rows stay Not run.

Choices for the parent

  • R30-6, an end recorded nowhere and an end stopped before any record of it counts: unchanged from round 31.
  • R30-5's last stopgap: with no record, 95% with thermal rules on while config.json is over 8 MiB, holds an Int64 on which Foundation stops the app, or is not read within 30 s.
  • A lock file whose reads keep failing: end the session (what the code does now), keep it, or defer it.
  • Hosted time: if the hosted run on 62d57c5 is stopped by the watchdog again, a longer watchdog or a split job is the parent's choice. This round changed no workflow.

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.

  • R30-1, a new end record written over old bytes in the lock file. RecoveryLockHandle.replaceContents now reads the bytes the file shares with the start of the record (pread on 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's record_end_in_lock already 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.recordSessionEndInLock now 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.
  • R30-3, a stale prefix at a start. Store.settleLockForStart runs 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's endedSession in 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.
  • R30-2, lines cut short in insomnia.log. A record at the end of the log that lacks only its newline counts as an end. Every writer of the log now reads the file's last byte first and, when that byte is not a newline or cannot be read, puts a newline before its own line. OwnerOnly.appendToLog (every app line) and LogEndRecord.append in the app, and log and record_end_in_log in the agent, do so in the same write. The LaunchAgent's own refusal line reads the last byte with tail -c 1 and appends the newline in a write of its own just before the line. That line is in LaunchdBackstop.agentProgram and in install.sh's AGENT_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's AGENT_PROGRAM line now differs from main's.
  • R30-4, journals the app loads that the check refused. The check now looks at a frozen process's startedAtMicros only after a startedAt that is there and not null, and at bootSession only after both, as FrozenProcess decodes 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 a Double holds it exactly, so 1e18 passes for an Int64. UTF-32 without a byte order mark, and UTF-32BE with one, are read through iconv. A sessionCutoffs written twice, with an escape JSON does not have, or as 1. 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 than sessionCutoffs), a whole number written with a fraction or an exponent that a Double does not hold exactly (9007199254740993.0), a number a Double rounds (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.
  • R30-5, cutoffs when the binary cannot answer. When the binary cannot answer for config.json, the agent now reads the file itself (config_cutoffs). It takes one bounded copy of at most 64 KiB. plutil must parse the copy, and record_text_problems must 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's Config decoder 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 takes endFloor (10 when absent or null, clamped to 0 to 95) and thermalRules (true when absent or null) and logs that it read them itself. A sessionCutoffs the 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's foreign answer 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.
  • R30-6, an end recorded nowhere. No code changed. README and spec section 8 now name a second history that a relaunch resumes: 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), which has undone nothing yet. They say that neither this nor the full-disk history is accepted, and that nothing on disk tells either from a crash. They also say that refusing a resume while the log takes no line at launch would not close the full-disk history, because once every file takes writes again the launch sees the same files as after a crash.

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.

  • LockEndRecordTests:
    • testAWriterStoppedPartwayNeverLeavesLessOfThisEndThanItFound stops 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.
  • LogEndRecordTests: testEveryAppWriterStartsOnALineOfItsOwnAfterALineCutShort, testTwoRotationsKeepARecordWithoutItsNewlineAndALineCutShortApart, testTheTickAdoptsARecordWithoutItsNewlineAndKeepsIt, testTheAgentsLinesLeaveARecordWithoutItsNewlineWhole and testTheAgentsRecordAfterALineCutShortReadsBackOnTheFirstAttempt. 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.
  • OwnerOnlyTests testALineCutShortIsEndedBeforeTheNextLine and LaunchdBackstopTests testAgentProgramsRefusalLineNeverJoinsALineCutShort (the LaunchAgent's program run as launchd runs it, with codesign failing).
  • RecoveryScriptTests: 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, as 1. and as +1).
  • CutoffAgreementTests: testTheAgentReadsConfigItselfWhenTheAppBinaryCannotAnswer replaces testTheAgentEnforcesTheStrictestCutoffsWhenTheAppBinaryCannotAnswer, whose name stated the old rule. Three tables run each policy with each way the binary fails: testTheAgentEnforcesEachPolicyInConfigAsTheAppsBinaryDoes, testARejectedConfigLeavesTheRecordOrTheDefaultsWithOrWithoutTheBinary and testAConfigReadNeitherWayLeavesTheRecordOrTheStrictest.
  • Assertions that changed on purpose: rows that expected the strictest values for a foreign record or a symlink to nothing now expect the defaults, and the agreement table's rows for a record twice or with a bad escape now expect the journal to load. In a41341c, the policy tables expect the restore call only when the journal records the sleep hold (no state.json or a symlink to nothing ends without it), and expect no line about the binary from a run that stops on an unreadable journal, which stops before it asks the binary.
  • No test was added for a failed cleanup specific to R30-2 beyond the existing cleanup tests.

Round 31 verification

All runs used fakes only. Each discovery and each run passed --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests. swift test list --skip-build exits 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-build directly, without the shared test lock, on drafts over f2298fe before each commit:

Run Tests Result Seconds Cause of failures
pre-a lock, log, owner, agreement and script tests, 140 132 passed, 8 failed (79 assertion lines) 252 Old tests against the new code: the old strictest rule, a record twice, a rejected config, the old lock writer, an unreadable lock file no longer written over, a stale record now emptied at resume, and byte counts after appendToLog started ending a line cut short. Rewritten for the new rules.
a-lock-owner 38 34 passed, 4 failed (10 assertion lines) 34 Test setup: a lock file made append-only before the app opened it made the app's open fail; the owner test's logs folder did not exist.
b-lock-owner the same 38 38 passed 33
c-log 42 40 passed, 2 failed (7 assertion lines) 24 Test setup: a file size limit on a log that was not empty, and an uninstall case whose fake GREP had to write the cut line first.
d-log-journal 46 43 passed, 3 failed (5 assertion lines) 41 Two tests compared the helper's old signature comment; the "record twice" row still expected a refusal.
e-journal-tables 7 7 passed 55 Comments were edited during this run, so it covers no single source.
f-followup the 7 table tests a41341c changed 7 passed 94

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:

  • Full run 1 on 159a570 failed (05:53:17Z to 06:15:09Z, exit 1). 1374 cases were expected, started and ended; 1372 passed and 2 failed (27 assertion lines), with no skip and no duplicate. Both failures were the new policy tables' own expectations: they expected a restore call when no journal records a sleep hold to undo, and a line about the binary from a run that stops on an unreadable journal before it asks the binary. a41341c corrects both. pmset -g log shows no sleep in that window.
  • Full run 2 on a41341c passed (06:22:34Z to 06:43:46Z, exit 0). 1374 cases were expected, started, ended and passed, with no skip and no duplicate, in 1271.8 s. pmset -g log shows one 5 s idle sleep at 06:41:51Z.
  • Static checks on a41341c (06:23Z, clean tree): ShellCheck 0.10.0 exited 0, and bash -n under /bin/bash 3.2.57 passed on all 7 scripts.
  • swift build -c release -Xswiftc -warnings-as-errors on a41341c exited 0 with no warnings, built from nothing in a private scratch path (06:45:11Z to 06:45:29Z); in .build the 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.
  • Hosted CI and Greptile on a41341c had not reported when this was written and are not counted here. Hardware and release rows in docs/release-validation.md stay Not run, and this round adds none. No test stops a real app process between its ftruncate and its pwrite, or the agent between > and its write.

Choices for the parent

  • R30-6, an end recorded nowhere (a full disk or I/O error everywhere) and an end stopped before any record of it counts. A: accept both as the documented open limits. B: refuse a crash resume while insomnia.log takes no line at launch. That covers only a log that still refuses at launch, not a full repair, and it stops a healthy crash resume whenever the log cannot take a line. C: never resume after a crash, which closes both and ends every healthy crash resume. The recommendation is A, because B does not close the history it targets and C removes crash resume for everyone. None is implemented.
  • R30-5 stopgaps. With no record, the defaults apply while config.json is missing or rejected, and 95% with thermal rules on while the file is there but neither reader can read it. Each can differ from what the app enforces. The alternatives in round 29's 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).

  • Item 1, an end recorded nowhere (R26-4). This round builds the log record that round 27 assessed. When neither folder takes a new file and the recovery lock file takes no write, the app (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 one write(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 --purge removes the logs only once session.json is gone. What remains, not waived:
    • A log that cannot be read, or one over 64 MiB, holds no record for any reader.
    • A session.json over 64 KiB is never recorded there.
    • Lines the app writes without the lock never rotate the log, so it can pass 1 MiB until a line written under the lock rotates it. A rotation also waits while .1 cannot be read, or while session.json cannot be read and .1 holds any record.
    • A run whose read-back fails counts the end as recorded nowhere. The next run uses a whole line already in either log, or appends another.
    • On a full disk or after an I/O error the log takes no line either, and the end is recorded nowhere, as before.
    • A record matches bytes, not a session. A session.json written later with the same bytes (the same start and end times to the second) reads as ended too.
  • Item 2, the journal check against the app's decoder. check_journal still reads every object and array, but now checks only what Store.decodeState decodes: the top level, the arrays under frozenProcesses, frozenPids, savedAudioOutputs and appNapOverrides, 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 a Double rounds 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. under sessionCutoffs; 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.
  • Item 3, the folder uninstall.sh takes its scripts from. uninstall.sh finds its own folder with script_dir(): parameter expansion on BASH_SOURCE[0], then CDPATH='' cd -- … && pwd, with no dirname. testUninstallFindsItsCheckoutWithoutPATHOrCDPATH runs uninstall.sh by a relative path from a checkout, with a dirname and a cat first 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 old cd "$(dirname …)" line, runs the decoy's. testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwn no longer compares the line word for word: it runs each shipped script's code up to in_checkout from a zip folder whose parent looks like a checkout. PathSubstitutionTests gains dirname and iconv stand-ins. The bare cat in bounded() stays on install.sh's word-for-word line; uninstall.sh calls bounded() only with fixed paths, never $SUDO, so that cat never runs. install.sh still runs a bare dirname for its own folder. That line is main's and is outside this PR.
  • Item 4, the lock file record and cutoffs with no record. Each reader now tells four states of the lock file apart: empty, a record, content read whole that is no record, and a file that cannot be read. Content read whole that is no record (a write cut short, other bytes, more than 1 MiB) ends no session, since no writer counts a record before it reads it back whole, and the agent empties it. A lock file that cannot be read still counts as the end of whatever session.json holds, since it may hold that record. A lock file over 1 MiB no longer refuses a start. In three cases no decoder or record says which cutoffs the app enforces: config.json is there, the binary cannot answer and the journal has no record; sessionCutoffs holds 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 file policy-ambiguity.md lists 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.
  • Item 5, hosted CI time. Twelve tables now run each row in its own home, eight at a time: main's five kept-display and typed-corrupt journal tables, the 21-row sessionCutoffs table, 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. The sessionCutoffs table 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.
  • Item 6, docs. README, spec sections 6 and 8, SECURITY.md and .greptile/rules.md now 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:
    • Round 27 said "the scripts may refuse a journal the app loads, never the reverse" and that a table checks "that the app, the binary and both scripts agree on that rule". The rule holds for the parts the app decodes. Round 29's check accepts more (item 2), and the refusals listed there are the ones that remain.
    • Round 27 said that on a Settings double failure "the session ends on disk before the recovery lock is released". That holds while some place takes the end record. When none does, 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 the end runs. A crash or hang before then is the same gap as any end recorded nowhere.

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 while SleepDisabled still reads 1.

This body said at 277b62a that no code changed for this finding and that it stayed open. f2298fe changes the code:

  • Both readers (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, then ftruncate), 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.
  • No writer empties content that counts. The app's write over it only adds to it before the cut. The agent appends the rest of the record to its first bytes with >>, 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_record keeps 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.
  • Unchanged: the app already checks the lock file before the log, so a relaunch names the lock file for both forms.

What remains, not waived:

  • A record cut short matches only the bytes it has. A stale one is also the start of a later session's record while it stops before the first byte where the two session.json files differ, and then ends that session. A start empties the lock file once it has written its new session.json, so that takes a start stopped in between, or a person.
  • A stop before the first byte of the record is written records nothing, as a stop before the write does: the round 27 case under "Not covered", where a relaunch after a full repair resumes the session.
  • Content kept while session.json cannot be read stays until the run after the one that removes session.json.
  • No test stops the app's real writer between its write and its cut, or the agent's between its > and its write. A file size limit cannot stop a shrinking ftruncate, 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_FSIZE with 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 fake rm has 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.
  • The every-shape table: rows "no newline", "two newlines", "cut short" and "trailing byte" now expect an end on both sides, and new rows cover the first byte, a cut inside the base64, the record with 4096 old bytes and another session's record cut short (no end).
  • testARecordLeftByACleanupWithoutTheLockIsEmptiedByTheNextRun adds 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.
  • Eight mutants were each killed: either reader ignoring the cut-short record, the agent writing > 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 -0 and sleep 1 take 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 stops swift test about 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:

Job Selection Tests Projected hosted test time
A swift test --filter '^InsomniaTests\.RecoveryScriptTests/test[A-T]' 221 612 to 636 s
B swift test --skip '^InsomniaTests\.RecoveryScriptTests/test[A-T]' 1203, including all 69 UIStatusTests, UIStartupTests and KeychainStoreTests cases 637 to 667 s
  • Each job keeps checkout, the toolchain print and the same watchdog loop around 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.
  • Job B keeps the bash 3.2 syntax check, the tmux install, swift build -c release -Xswiftc -warnings-as-errors (about 44 s on main) and scripts/check-lid-simulation-gate.sh (about 42 s). No RecoveryScriptTests case uses tmux, so job A can skip it.
  • Union and duplicates: after its run, each job lists the catalog (swift test list --skip-build), selects its IDs with the same regex (grep -E in A, grep -vE in 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 cost is a second macOS runner and a second build.

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

  • LogEndRecordTests (new, 12 tests): the store appends the record only under the held lock and counts it only once it reads back; ordinary ends write no record; the agent's record reads back whole beside other lines and locked rotations; a record the agent cannot read back does not count for it; the app and the agent read every shape of line alike (cut short, other bytes, other size, extra spaces, in .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 .1 holds 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.
  • LockEndRecordTests: two tests renamed and rewritten for item 4, since their old names stated the old rule. testAStaleRecordEndsNoNewerSessionAndOnlyContentThatIsNoWholeRecordDoes is now testAStaleRecordOrContentThatIsNoRecordEndsNoNewerSession, and testContentThatCannotBeReadWholeCountsAsTheEndUntilSessionJSONIsGone is now testContentThatIsNoRecordEndsNothingAndAnUnreadableFileCountsAsTheEnd. The every-shape table expects content that is no record to end nothing, on both sides.
  • RecoveryScriptTests adds testUninstallFindsItsCheckoutWithoutPATHOrCDPATH (item 3), testUninstallPurgeKeepsTheLogsWhileSessionJSONIsThere (item 1) and testWholeNumbersWrittenWithAFractionReachTheBinaryAsDigits (item 2; its control, a pid of 5100.5, is refused and runs nothing).
  • Existing assertions that changed on purpose:
    • testTheAppTheBinaryAndBothScriptsAcceptTheSameJournals: rows the check now accepts (a pid written 5105.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 a Double rounds, keys twice where the app reads nothing, values the app skips, and UTF-16 and UTF-32 with and without a byte order mark.
    • The end tests where nothing takes a record now also refuse the log record (refuseLogRecord), so they still cover that case, and their log text adds "or the log file".
    • OwnerOnlyTests.testLogPastTheCapRotatesToDotOneAndStartsAFreshFile holds the recovery lock, since a line written without it no longer rotates.
    • testTheZipsScriptsTakeNothingFromTheFolderAboveTheirOwn runs the scripts' code instead of matching the dirname line (item 3).
    • The two CutoffAgreementTests rows whose binary hangs now expect "did not answer within 5s".
  • A DEBUG-only seam, Store.lockRecordWriteLimitForTesting, lets tests make the app's lock record write fail, as PatchedBackstop.refuseLockRecord does for the agent. Release builds compile it out.
  • No test covers the start refusal while the lock file cannot be read: a lock file a test makes unreadable cannot be locked either.

Round 29 verification

Every focused run below ran swift test --skip-build directly, without the shared test lock, with an anchored filter and --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests. Discovery was swift test list --skip-build with 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):

Run Tests Result Seconds Cause of failures
log1 LogEndRecordTests, 12 11 passed, 1 failed (11 assertions) 18.2 Test bug: the Settings test denied new files before updateConfig, so Settings refused the change early. The denies now apply just before the old record is put back (beforeRecordedCutoffsPutBack).
log2 the Settings log test, 1 failed (1 assertion) 5.2 Test bug: the pending end waits on the app's 60 s retry timer. The test now runs the end after the repair.
item1a log, lock, end and owner tests, 72 72 passed 99.0
item3 provenance tests, 7 6 passed, 1 failed (3 assertions) 11.3 Test bug: the fixture's own fakes called the stand-in cat, and the fake launchctl bootout exited 5. The stand-ins now answer only uninstall.sh.
item3b the same 7 7 passed 12.2
item2a journal agreement tests, 3 2 passed, 1 failed (1 assertion) 9.5 Wrong expectation: Foundation reads 1e-99999 as 0 for an Int32, so the app loads that row.
item2b the same 3 3 passed 9.3
item2c journal and PATH tests, 9 9 passed 26.3
item4a lock and log record tests, 40 40 passed 114.4
timing-before 19 slow tests, before the change 17 passed, 2 failed (4 assertions) 1208.8 Sleep stalls: Clamshell Sleep at 12:41:17 PDT, then Maintenance Sleep periods of 6 to 600 s (pmset -g log).
timing-cpu 7 of them, serial, CPU time 6 passed, 1 failed (4 assertions) 878.7 A sleep stall (13:09:40 to 13:11:56 PDT). Files were edited during this run, so it counts only for its CPU figures.
timing-after the 19, eight rows at a time 15 passed, 4 failed (27 assertions) 235.8 With 1 s limits, fakes that answer at once did not start in time with eight runs going. No sleep in that window. Fixed with concurrentRow() and the 5 s hung-binary limit.
timing-after2 the same 19 19 passed 248.8

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.

  • Full run 1 failed. 1355 cases started and ended, the same set as the list; 1354 passed and 1 failed, with no skip, no duplicate and none of the three classes, in 1467.5 s (20:58:28Z to 21:22:46Z, exit 1). The failure was RecoveryScriptTests/testSessionWithOffsetDatesIsReadLikeTheApp, a test this round did not change. The fake sudo did not finish within the fixture's 1 s limit, so the backstop exited 1 with no call logged. pmset -g log shows 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).
  • Full run 2 also failed. Started at 21:25:14Z, after run 1's failure, on the same tree: 1355 cases started and ended, the same set; 1353 passed and 2 failed, with no skip, no duplicate and none of the three classes, in 2801.6 s (exit 1). The lid was closed again: pmset -g log shows 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. testEndFloorIsReadFromConfigAndZeroDisablesIt failed 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. testInstallStopsAndLetsGoOfTheLockWhenLaunchctlPrintDoesNotAnswer took 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.
  • No full run passed on 277b62a. Every failure falls in a sleep window in 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.
  • Static checks: ShellCheck 0.10.0 exited 0, bash -n under /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-errors exited 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.
  • Hosted CI and Greptile on the pushed head were not done when this was written and are not counted here. Hardware and release rows in docs/release-validation.md stay Not run; this round adds none, since a lock file that takes no write cannot be set up by hand without filling a volume. The historical 14 temporary-Keychain cases are unchanged and were not run. The required reading of earlier rounds' evidence was done in part; the round 27 result, the round 28 report inline in the brief and the hosted logs were read, not every file in the earlier evidence folders.

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.

Run Tests Result Seconds Cause of failures
p1-lock1 LockEndRecordTests, 16 14 passed, 2 failed (2 errors) 24.6 Test bug: the relaunch test and the agent test read the lock file's inode before any run had made the file. Both now let a reconcile make it first.
p1-lock2 the same 16 16 passed 26.8
p1-lock3 the same 16, after more edits 16 passed 25.9
mutants, attempt 1 the same 16, once per mutant, M1 to M7 each run failed 25 to 28 each Only M1 and M6 count. The script rebuilt after it applied a Swift mutant but not after it put the source back, so M2 to M5 ran on M1's binary and M7 on M6's.
mutants, attempt 2 the same 16, M2 to M5 and M7, on a binary built from the restored source each run failed 1 or 2 tests 25 to 28 each
p1-related1 CutoffAgreementTests, JournaledSessionEndTests, LogEndRecordTests, ReconcileTests, RecoveryLockTests, StoreTests, 153 153 passed 195.2
p1-cleanup1 the cleanup test, 1 failed (1 assertion) 0.2 Wrong expectation: the test looked for the agent emptying the lock file in the same run that could not read session.json. That run keeps the content and removes session.json; the next run empties it.
p1-cleanup2 the same 1 failed (1 error) 0.3 Test bug: that run had already removed session.json, so the test's step that gave the file back its mode threw.
p1-cleanup3 the same 1 passed 0.2
M9 LockEndRecordTests, 16 15 passed, 1 failed (3 assertions) 25.6 The mutant: the cleanup test caught the content emptied while session.json could not be read.
p1-lock4 LockEndRecordTests, 16, the committed contents 16 passed 25.4

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.

  • Full run 3 failed. The list held 1427 IDs, 69 of them in the three skipped classes. 1358 cases started and ended, the same set as the list without those 69; 1356 passed and 2 failed, with no skip, no duplicate and none of the three classes, in 2445.1 s (23:47:52Z to 00:28:38Z, exit 1). Both failures are uninstall tests that did not change: testUninstallAbortsWhenAgentIsStillLoadedAfterBootout and testUninstallAbortsWhenBootoutAndPrintBothFailAmbiguously. In each, the backstop run inside uninstall.sh exited 1 with sleepDisabledByUs still journaled, so uninstall stopped before it called launchctl. Neither test writes session.json, and the lock file is empty there, so the lock record code f2298fe changed returns at once. pmset -g log shows 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.
  • No full run passed on f2298fe, as none did on 277b62a. Every failure in the three full runs falls in or just after a sleep window in 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.
  • Static checks on f2298fe (23:50:26Z, clean tree): bash -n under /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-errors exited 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.
  • Hosted CI and Greptile on f2298fe were not done when this was written and are not counted here. Hardware and release rows in docs/release-validation.md stay Not run, and this change adds none. No test ran the real app or agent and stopped it between its write and its cut, or between the agent's > 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.

  • [P1] Finding 1, a journal the app does not load (Greptile 4219151866). With config.json missing and the battery at 20%, --agent-session-cutoffs read "30 false" from a state.json whose frozenProcesses was "bad", a journal Store.loadState rejects. 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:
    • backstop.sh checks the whole journal (check_journal: plutil, the shape checks and record_text_problems) before it reads the cutoffs from it, and before it ends a valid session, removes session.json or records an end, --force included. 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-cutoffs decodes the whole journal with Store.decodeState, the decoder Store.loadState runs, before it reads the record. It answers rejected (exit 65) for a journal the app does not load, which stops the run the same way, and foreign (exit 65) for a record the app does not write. InsomniaAgentCutoffsVersion is 3, so the agent never asks an older bundle's binary.
    • The scripts may refuse a journal the app loads, never the reverse. The app writes none of these: a key twice in one object (the app takes the first copy, plutil the last), an integer written as 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.
  • [P1] Finding 2, a Settings rollback that fails (Greptile 4219151883). During a session Settings records new cutoffs in state.json, then saves config.json. When that save failed and the old record could not be put back either (state.json made immutable between the two writes), the app stayed on the old cutoffs while the journal held the new ones, and Settings said both stayed old. With the app hung and config.json missing or rejected, the agent reads that record, so it kept a session at 20% that the app's 30% floor ends (30% to 0%), or ended one at 20% that the app keeps (0% to 30%). Now the session ends on disk before the recovery lock is released (endSessionOnDisk, the same removal and records performEnd uses): session.json is removed, or, when it cannot be, its end is recorded. The app then ends it in process as cutoffsNotRecorded, and Settings says the session ended. The cost is that a Settings change during this double failure ends the session.
  • [P1] Finding 3, tools from PATH (Greptile 4219151895). backstop.sh took cat, grep, head, tr, stat and id from PATH in its readers, and uninstall.sh took cat, head, tr, awk, id, basename and dirname. This PR added the cat in run_read, both battery grep reads, the two end record copies, the cutoff answer excerpt and one head check; 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. dirname finds the script's own folder, and cat reads a sudo call's pid in bounded(), which uninstall.sh never runs for sudo. 958ba1a had changed both, the full run on 277b77f failed main's two tests for them, and 47bdc6c puts main's text back. sleep stays by name too: it reads nothing, and main's slow-poll tests replace it through PATH. run_read keeps 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.
  • [P1] Finding 4, an end recorded nowhere. Assessed, not changed. The details are under "Not covered".
  • [P2] Finding 5, the cutoffs when the binary cannot answer. bec766b enforced 95% with thermal rules on whenever the binary could not answer. That ends a session with the floor off below 95% and applies thermal rules the user turned off. Now the agent reads sessionCutoffs from the journal it has checked (journal_cutoffs, which takes only the text the app writes: a floor of 0 to 95, a space and true or false), 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%.
  • Finding 6, the hosted CI watchdog. The hosted run on bec766b started 1048 cases and finished 1047, all passing, with 1188 s of test time when the workflow's 20-minute watchdog stopped it. No product timeout and no workflow line changed. 1e1178b gives four end tests that deny new files in the folder of the agent's status files a 2 s limit for their fake commands, instead of the production 30 s plus grace (about 38 s each before). They still check the exit 1 on the missing status, the end recorded before the undo, the lock inode, and a relaunch that neither resumes nor holds sleep. The Float and Integer end floor tables keep every row and both agent runs per row, and now run each row in its own home, eight at a time. In the local full run on 277b77f the four end tests took 9.5 to 10.1 s each (37.7 to 38.5 s on bec766b), and the Float and Integer tables 19.8 and 12.9 s (35.8 and 23.0 s). The cases both runs share took 129 s less. The merged head still runs more: 1340 cases in 1230.7 s locally, against 1187 in 1102.5 s on bec766b, since Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43 brought 145 cases (204 s) and this round 8 (53 s). The hosted estimate is about 1377 s of test time, or 1470 to 1490 s with the 96 to 111 s test build, 270 to 290 s over the watchdog. This is a projection, not a hosted run, and it stays under "Not covered".

Merge of main. 7a28ce0 merges b5f7cf0 (#43) with a merge commit. The conflicts were resolved so both sides keep their behavior:

Docs. README, SECURITY.md, spec sections 6, 8 and 10, .greptile/rules.md and 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

  • RecoveryScriptTests adds three. testAJournalTheAppDoesNotLoadKeepsAValidSessionTheRunWouldEnd runs 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, no sudo or kill, and the log naming the cause. After a repair the next run ends the session. testAJournalTheAppLoadsLetsTheRunEndAValidSession is 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. testTheAppTheBinaryAndBothScriptsAcceptTheSameJournals reads 33 journals through Store.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's testTheRecordReaderFollowsTheTopLevelAsTheAppReadsIt gains 17 rows (keys twice at any depth, Kelvin signs, Int32 and Int64 ends, numbers and escapes JSON does not have).
  • CutoffAgreementTests adds three. testACutoffChangeWhoseRecordCannotBePutBackEndsTheSession replays 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 as cutoffsNotRecorded. The controls put the record back, and the agent then enforces the old cutoffs at 20%. testTheAgentReadsTheRecordItselfWhenTheAppBinaryCannotAnswer covers 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. testTheAgentKeepsTheSessionWhenTheAppsBinaryRejectsTheJournal has a stand-in binary answer rejected, and the agent stops with the session and the journal kept.
  • AgentCutoffsCommandTests: the 20 values the app does not write now answer foreign. 9 more journals that the app rejects for another key answer rejected and fail Store.loadState, and the built binary answers rejected for one.
  • PathSubstitutionTests (new) 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 runs. dirname has no stand-in, for the reason above. PatchedBackstop's fakes now call /bin/cat, and failLockReadBack fails only the read of the lock file.
  • Existing assertions that changed on purpose:
    • testTheAgentReadsTheRecordAsTheAppDoes had three rows with sessionCutoffs twice, 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").
    • In Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43's reader test, a UTF-16 journal without kept display records passed before. Every UTF-16 journal now fails the check, because the reader cannot follow it and now checks every key. The other changed rows keep their outcome with the new wording ("the text of state.json", "the keys the app reads in it").
    • Version texts read AgentCutoffsCommand.version instead of a literal 2.
    • No assertion of the end durability tests changed.
  • Before the fixes. With the scripts before 958ba1a, both PATH tests failed (13 assertions). With the old rollback catch, the double failure test failed 70 assertions, in both directions and with session.json removable and immutable. With 958ba1a's scripts (their version line set to 3 so that they still ask this build's binary), this build's binary and tests, 6 of 7 tests failed with 257 assertions and the control passed. In all 28 runs of the 7 journals in 4 modes the old agent ended the session and removed session.json. When the binary could not answer it applied 95% with thermal rules on, and it ended the session on the binary's rejected answer. The 33-journal table stopped at its first assertion, because the old scripts have no journal_cutoffs.

Round 27 verification

Every focused run below ran swift test --skip-build directly, 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.

  • After the merge: 10 classes touched by Display: refuse the private brightness calls on an unmeasured macOS or a changed KeyboardBrightnessClient #43 (293 tests) had 3 failures, the three EarlierBootLowPowerClaimTests routes fixed in 2acc91d. The class then passed 13 of 13.
  • Fixture timing at 2acc91d with a draft of 1e1178b uncommitted: 7 tests, 6 passed. testJournalWithSessionCutoffsTheAppDoesNotWriteIsStillUsable failed 10 assertions: uninstall exited 1 after only two pgrep -x Insomnia calls. That test does not use the changed fixtures, and it passed in every later run on committed source. The cause is not known.
  • Fixed tools: 9 tests passed with a draft of 958ba1a's scripts. The committed versions of these and the earlier focused runs' tests ran again in the full runs.
  • Finding 1 and 5 tests at 958ba1a with 844947d's change uncommitted: 27 tests, 2 failed with 6 assertions, all test bugs. One assertion checked contains(""), and one test wrote an older journal from the wrong source. After the fixes the 27 passed, in the ShellCheck run below.
  • Finding 2 and the end path tests: 46 tests, 1 failed with 8 assertions, a test bug. Each disk copy's new home moved INSOMNIA_HOME to itself, so the app log lines the test checks went to the copy. diskCopy now points it back, and the 2 rollback tests passed on source equal to 844947d's (same fingerprint).
  • ShellCheck exited 1 at 844947d (SC2209 and SC2071 in record_text_problems in 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).
  • Full runs with the shared lock (/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, and pmset -g log showed no sleep or wake during either.
  • Probe replays on 47bdc6c, with untracked copies of the two probe files that were removed afterwards. The round 25 probe passed 6 of 6. The reviewer's round 26 fault probe failed 2 of 3, both on this round's intended changes. The weaker rollback passed. The stronger rollback, off to 30%, failed its last check because the app now ends the session when the old record cannot be put back, which is finding 2's fix and its cost. The agent then found no session and read no battery. The malformed journal case failed only its check that the binary answers cutoffs 30 false. The binary now answers rejected with 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 drops DYLD_INSERT_LIBRARIES, so its fault never applied and its results count for nothing. The second ran the toolchain's swift directly, which applied it.
  • Static checks, each on 277b77f and again on 47bdc6c, with a clean tree: bash -n under /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-errors exited 0 with no warnings, and scripts/check-lid-simulation-gate.sh exited 0.
  • Hosted CI and Greptile on the pushed head were not done when this was written and are not counted here. Hardware and release rows in docs/release-validation.md stay Not run. The historical 14 temporary-Keychain cases are unchanged and were not run.

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".

  • [P1] R1, the end of a session that nothing records. The reviewer denied new files in both Application Support and ~/Library/Logs/Insomnia and 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 with SleepDisabled 1 (from a failed restore, or set again later by another program) resumed the same bytes and sent disablesleep 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:
    • The record is ended-session-v1, a space, session.json's bytes in base64 and a newline. The agent writes it with printf while 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.
    • The app (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.
    • Reconcile, the 1 Hz tick, the adoption of the agent's end, every transaction, every agent run and uninstall.sh in both modes read it. Whoever removes session.json under the lock empties the record in place. The agent's next run empties a whole record of other bytes. Start empties it once its new session.json is written, and a Start that fails puts back the old session.json bytes and the lock file's content together. Start refuses while a session.json exists and the lock file cannot be read whole, since a rollback could not put that content back. uninstall.sh empties the record in place with and without --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.
    • The reviewer's case is closed for a failed restore and for a later hold alike: after the full repair the relaunch ends the session, sends no disablesleep 1, removes session.json and empties the lock file, whose inode stays the same.
  • [P1] R1, the reader of ended-session.json. backstop.sh removed ended-session.json whenever cmp did 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 or cmp reports other bytes (exit 1). While cmp cannot 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.
  • [P1] R3 (and Greptile 4217047024). A hung session lost its selected floor when config.json turned rejected or went missing. The reviewer started a session at 30% with thermal rules off, kept the app holding the alive lock without answering, and added "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 with sleepDisabledByUs before 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.
    • backstop.sh uses config.json as before when the binary decodes it. When the file is missing, unreadable or rejected, it reads the journal's record through the same binary, Insomnia --agent-session-cutoffs 33, which decodes state.json with the app's own reader and answers cutoffs <floor> <true or false>, none or rejected. 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.) InsomniaAgentCutoffsVersion is 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.
    • The reviewer's case is closed: with config.json rejected or missing, the agent keeps the session at 40% and 31% and ends it at 29% and 20%. Duplicate and escaped keys, 1e-400 (0) and 4.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.md and docs/release-validation.md (one row corrected for the lock file record, three added) describe the record in the lock file, the canonical rule and sessionCutoffs.

Tests for round 25

  • Probes (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 sent disablesleep 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).
  • LockEndRecordTests (new, 13 tests) covers the reviewer's case with a failed restore and with a later hold, the app's own end recorded only in the lock file, the running app's tick, a record the agent cannot read back, an ordinary end and a writable journal leaving the file empty, a crash with an empty file or a stale record resuming (the healthy controls), content that is no whole record, a Start emptying the record and a failed Start putting it back, a record left by a cleanup without the lock, a symlink at the lock path, and the app and the agent reading 18 shapes of content alike. Each checks that the inode stays.
  • CutoffAgreementTests adds 7: the reviewer's hung session at 30% (rejected and missing, five battery and heat cases), the agent reading 11 journal rows as the app does (valid, duplicate and escaped keys, absent, null, out of range, a number, another word) and no state.json, a Settings change recorded before it takes effect, a change that cannot be recorded (immutable state.json, busy lock, immutable config.json) changing neither side, the tick recording the cutoffs in use, a session whose cutoffs cannot be recorded ending, and a resumed session recording them.
  • AgentCutoffsCommandTests adds 4: each mode answering only for its own file, a 33-row table compared with the app'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.
  • RecoveryScriptTests replaces testEndWhereNeitherFolderTakesANewFileRecordsNothingAndKeepsTheJournal with 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 while cmp cannot 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 7 sessionCutoffs values 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.
  • JournaledSessionEndTests adds 2 (an exact record kept while session.json cannot be read ends the session once it can; a record of an earlier session ends no newer one), its three nothing-recorded cases now also refuse the lock file record, and its Support-only refusal cases check that the lock file stays empty with the same inode. RecoverySafetyTests expects the session's cutoffs in the journal while its session.json stays.

Round 25 verification

Every focused run below ran swift test --skip-build directly, 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.

  • An affected run before the existing tests were updated: 405 tests, 5 failed. Each was an existing test whose expectation this round replaces: the three JournaledSessionEndTests cases that recorded nothing, the case recorded nowhere while session.json cannot be replaced (all four now find the record in the lock file), and RecoverySafetyTests, which now expects the session's cutoffs in the journal.
  • A second run of the new tests: 107 tests, 8 assertions failed in 3 tests, all test bugs. A rollback test wrote a session without extensions, so Start refused before the rollback; one test read an empty log; one expected the wrong log line.
  • A third run of 17 classes: 582 tests in 1048 s, 3 assertions failed in 2 tests. One test still expected the battery read to be the run's first read; with no config.json it is now the second, after the journal read. The other expected uninstall to name a symlink at the lock path. Instead uninstall stops before it removes anything, as on main, so the test now checks that.
  • A fourth run of the classes changed since then and those 2 tests: 67 tests, 66 passed. The corrected uninstall test failed on its own reinstall of the fixture. After that fix it passed alone.
  • One full run on bec766b: /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 log shows no sleep or wake in the window (the last wake was 2026-10-07 17:29:55 PDT).
  • Static checks on bec766b with a clean tree: bash -n under /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-errors exited 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 with INSOMNIA_LID_SIMULATION).
  • Hosted CI and Greptile on bec766b were queued when this was written and are not counted here. Hardware and release rows in docs/release-validation.md stay Not run. The historical 14 temporary-Keychain cases are unchanged and were not run.

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".

  • [P1] R1. An end recorded nowhere could still be resumed once the folder and the journal were repaired (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 sent disablesleep 1. Two changes:
    • A fourth record location. When the folder beside session.json takes no new file, the agent and the app write the record aside in the log folder, ~/Library/Logs/Insomnia, under the same name shape (mktemp in the agent, O_EXCL in 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.
    • When neither folder takes a new file, nothing is recorded. Reconcile now replaces session.json with the same bytes (a temporary file renamed over it and read back) after it writes the journal and before it holds sleep, and ends the session when that fails. An end that recorded nothing left a session.json it could not remove, so a relaunch that cannot replace the file does not hold sleep for it, whatever SleepDisabled reads. The cost is that a crash while session.json cannot be replaced ends the session at the next launch instead of resuming it.
  • [P1] R2. A duplicate or escaped top-level key read differently on the two sides (scripts/backstop.sh, config_int). The app's decoder takes the first of two endFloor keys and plutil the last, so {"endFloor":95,"endFloor":0,...} gave the agent 10 and the app 95. endFloor in either place did the same, and a duplicate thermalRules key turned the agent's critical-heat end off while the app kept it on.
  • [P2] R3. A file the app rejects for another field (lowPowerFloor:"bad", presets:["bad"], lowPowerFloor:-9223372036854775809) with endFloor 0 and thermalRules false let the agent read endFloor 0 and skip the cutoff at 5% while a hung app ran on 10%.
  • [P2] R4 (and Greptile 4215544412). endFloor texts the decoder rounds read higher on the agent's side: 1e-400 is 0 to the app and 10 to the agent, and 4.9999999999999999 is 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_int and the plutil reads of the two cutoffs are gone. While a session is valid and the app holds the alive lock, read_cutoffs opens 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 in main.swift before AppKit starts) arms a SIGALRM for its lifetime, reads at most 8 MiB, decodes the bytes with Store.decodeConfig (the call Store.loadConfig makes), prints cutoffs <endFloor> <thermalRules> from Config.agentCutoffs and 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 declares InsomniaAgentCutoffsVersion 1, as it does for --resume-frozen. A missing, non-regular or unreadable file and the answer rejected give 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

  • The reviewer's probes (ReviewerRound22ProbeTests, 7 tests) ran unchanged at bf71148's source: 6 failed with 13 assertions. The failed-restore and other-hold cases resumed and sent disablesleep 1, the duplicate and escaped endFloor rows kept the session at 25% on the agent's side, the duplicate thermalRules row 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 in probe-after/adaptation.diff: the reviewer's noNewFileEnd asserted 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, no disablesleep 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.
  • CutoffAgreementTests adds six tests. Three read duplicate and escaped endFloor and thermalRules keys 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 covers 1e-400 and 4.9999999999999999 before and after a canonical save. One makes the binary unable to answer in seven ways (version withdrawn, binary missing, an answer out of range, rejected with exit 0, exit 65 with a cutoffs line, 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.
  • AgentCutoffsCommandTests (new, 9 tests) checks the mode in process (other arguments, usage errors before any read, the lifetime armed before the read, unreadable input, and a table compared with Store.loadConfig row 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).
  • JournaledSessionEndTests adds eight. Four port the reviewer's case with the record in the log folder (failed restore, other hold, the app's own end, the running app's tick and the agent). One checks that only a regular file owned by this user in a real log folder counts (symlink, FIFO, wrong name, unreadable record, symlinked folder). One covers an end recorded nowhere: after the repair, with session.json still locked, the relaunch ends the session and restores the hold. One shows the cost, a crash with session.json locked ending at relaunch, and the same crash resuming once the file can be replaced. One covers Store writing in the log folder only when its own folder refuses.
  • RecoveryScriptTests replaces testEndInAFolderThatTakesNoNewFileRecordsNothingAndKeepsTheJournal with 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.
  • Two ConfigTests comments that described the shell reader now describe the decoder read.

Round 23 verification

Every run took the shared test lock with /usr/bin/lockf -k and 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 ran swift test directly 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.

  • Focused runs: CutoffAgreementTests, AgentCutoffsCommandTests and JournaledSessionEndTests passed 51 of 52. The failure was in the new crash test: its control still saw the ended-session.json that 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.
  • Probes: 6 of 7 failed at bf71148's source; 9 of 9 passed on the final source.
  • Full suite on the final source: the first run passed 1154 of 1156. testValidSessionWithTheAppAliveAndAHealthyMachineIsLeftAlone (the backstop ended the valid session before any read) and testZeroMicrosecondsStillUsesTheAppBinary (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 log shows 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.)
  • Static checks on the final source: swift build -c release -Xswiftc -warnings-as-errors (0 warnings), ShellCheck 0.10.0, bash -n under bash 3.2.57 for all seven scripts, and the lid gate all pass.
  • scripts/install.sh, scripts/build-app.sh, SleepGuard.swift, Shell.swift and RecoveryLock.swift are byte-identical to main.

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.

  • [P1] R1. An end recorded nowhere could still be resumed (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 when pmset -g reads SleepDisabled 0. In two cases the bit still reads 1: the agent's own restore failed, or another program held disablesleep 1. After chflags nouchg on state.json, a relaunch resumed the ended session and sent disablesleep 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.XXXXXXXX from mktemp (mode 0600), and keeps it only when cmp finds the same bytes. An existing match is used again. The app's own end does the same with O_EXCL and 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. deleteSession removes 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".
  • [P2] R2. A config text the app rejects could give the agent a lower end floor (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 (-5 as -5, for example), so those are not new in 953b4b0. config_int now reads the number's own text first. config_number_text is a strict JSON tokenizer that finds the one top-level endFloor number. 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_value then applies the rules measured on the app's decoder: an integer anywhere in the Int range, 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 1 survived 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 rejected endFloor, all Not run.

Tests for round 21

  • The reviewer's probes ran unchanged against both trees (ReviewerRound20ProbeTests, 4 tests). At 953b4b0 three failed with 5 assertions: the failed-restore and other-hold cases each resumed the session and sent disablesleep 1 after 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.
  • JournaledSessionEndTests adds nine tests. testAnAgentCutoffThatCanWriteOnlyANewFileIsRecordedAsideAndNotResumed locks the three files and runs the real backstop.sh. It checks that the record aside (mode 0600) exists before the fake sudo runs, 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 reporting SleepDisabled 1: no resume and no disablesleep 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; and Store writing one record and removing it with the session.
  • RecoveryScriptTests adds four. testEndThatCanWriteOnlyANewFileRecordsItAside checks the record and the log. testEndInAFolderThatTakesNoNewFileRecordsNothingAndKeepsTheJournal makes 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. testStaleRecordAsideIsRemovedAndOthersAreLeft and testUninstallRemovesRecordsAsideAndNamesWhatItCannot cover the removal rules. testEndThatCanNeitherRemoveNorRecordKeepsTheSleepEntry and the three nothing-recorded JournaledSessionEndTests now make mktemp fail as well, so they still record nothing.
  • CutoffAgreementTests adds 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 (+30 reads 30; 4.9999999999999999, 1e-400 and -100000000000000000.5 read 10). Every row also asserts that the agent's floor is never below the app's.
  • PatchedBackstop gains failSudo(), refuseRecordsAside() and namesAtSudo, the folder listing taken when the fake sudo runs.

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.

  • Before the fix. The 24 new and changed tests ran with 953b4b0's backstop.sh and SessionManager.swift and the new Store, Paths and test code: 38 assertions failed, exit 1. Each R1 test that needs the agent or the app to write a record aside failed, and so did each R2 row where the old reader kept a session the app's floor ends. With the fix the same 24 passed.
  • Decoder parity. A private build of the app's Config decoder 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.
  • Focused regressions on the final scripts and app code (a comment in Store.swift changed after this run): CutoffAgreementTests 13, JournaledSessionEndTests 16, ReconcileTests 50, StoreTests 25 and 50 RecoveryScriptTests (the end-record, battery, thermal, config, early-end and uninstall cases), 154 tests, passed in 155 s, exit 0. The tests that ran match the selected list.
  • Static checks. /bin/bash -n (3.2.57) on all seven scripts and shellcheck scripts/*.sh (0.10.0) passed. scripts/check-lid-simulation-gate.sh, which runs swift build -c release -Xswiftc -warnings-as-errors for the plain and lid-simulation builds, passed.
  • Full suite at bf71148, as /usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with a bash -c wrapper inside the lock that logged the time and inode before it ran that swift test under /usr/bin/script with exec. 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, and pmset -g log shows 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.

  • [P1] R1, the rest of F3. An end the agent could record nowhere came back after a repair (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, kept sleepDisabledByUs and exited 1. After chflags nouchg on state.json alone, a relaunch wrote the journal and ran disablesleep 1 for the session the agent had ended. Reconcile now checks a journaled hold before it resumes: when the journal says Insomnia disabled sleep, pmset -g must still report SleepDisabled 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 its pmset. 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.
  • [P2] R2. An end floor of more than 18 digits split the two sides (scripts/backstop.sh, config_int). Swift decodes any Int and clamps the floor to 0...95, so Int.max is 95 and Int.min is 0. config_int read 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_int now takes a minimum and maximum and clamps as the app does across the whole Int range, 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 an Int holds 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

  • JournaledSessionEndTests port the reviewer's case. testACutoffThatCanRecordNothingIsNotResumedOnceOnlyTheJournalCanBeWritten locks all three files, runs the real backstop.sh (exit 1, sleep restored, nothing recorded), unlocks state.json alone and relaunches. No session runs and no disablesleep 1 is sent; session.json stays and the journal now holds the end (endedSession) with sleepDisabledByUs clear. 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. testACutoffThatCanRecordNothingIsNotResumedOnceEveryFileCanBeWritten unlocks all three files: no resume, session.json removed, journal clean.
  • ReconcileTests testAValidSessionWhoseJournaledHoldWasUndoneIsEndedNotResumed and testAValidSessionWhoseJournaledHoldCannotBeConfirmedIsEndedNotResumed (a pmset -g that fails) end the session with no disablesleep 1.
  • CutoffAgreementTests testTheAgentEnforcesTheEndFloorTheAppTakesFromAnyInteger and testTheAgentEnforcesTheEndFloorTheAppTakesFromAnyFloat write each value into config.json as raw JSON, take the floor the app's Store decodes, 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. testAnEndFloorOfIntMaxThatCannotBeRewrittenIsNinetyFiveOnBothSides and testAnEndFloorOfIntMinThatCannotBeRewrittenIsOffOnBothSides port 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.
  • Fixtures changed for the new rule. Tests that relaunch over a crashed session used to journal the hold and leave the fake's SleepDisabled at 0. They now set it to 1, which is what the machine reports after a crash: three AppNapTests, four ReconcileLidGatingTests, LidActionsTests.testADeviceChangeBeforeTheLaunchReconcileWaitsForTheLidOfTheSessionOnDisk, ReconcileTests.testAStaleEndRecordDoesNotEndTheSessionOnDisk and JournaledSessionEndTests.testARecordOfAnEarlierSessionDoesNotEndANewerOne. Exact call lists in six tests (seven assertions) gain the pmset -g read before the resume's disablesleep 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.

  • Before the fix. CutoffAgreementTests, JournaledSessionEndTests and ReconcileTests (69 tests) ran with the new tests on b96f62a's code. 9 tests failed with 45 assertions, exit 1: the four new CutoffAgreementTests (the agent kept the session the app ended, or ended the one it kept), both repair tests (each relaunch sent disablesleep 1 and resumed), both new ReconcileTests, and testValidSessionIsReappliedAndRearmed, which now expects the pmset -g read.
  • After the fix, first attempt. The 568 tests in the twelve affected classes were started as one run and stopped at the tool's 90-minute limit with nothing in the log. XCTest buffers its output when stdout is not a terminal. pmset -g log shows no sleep or wake in that window. Run again under /usr/bin/script, so the log streams, the first group stopped at AppNapTests.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 the disablesleep 1 the test's gate waits for. The fixture corrections listed above followed.
  • After the fix. With those corrections, on the tree committed as 953b4b0, the twelve classes ran in two anchored groups. AppNapTests 16, ConfigTests 28, CutoffAgreementTests 12, JournaledSessionEndTests 7, LaunchGateTests 6, LidActionsTests 100, ReconcileLidGatingTests 8, ReconcileTests 50, RecoverySafetyTests 29, StillRunningCommandTests 35 and StoreTests 25 (316 tests) passed in 67 s, exit 0. RecoveryScriptTests (252 tests) passed in 697 s, exit 0. In both, the tests that ran match the selected list.
  • Static checks. /bin/bash -n (3.2.57) on all seven scripts and shellcheck scripts/*.sh (0.10.0) passed. scripts/check-lid-simulation-gate.sh, which runs swift build -c release -Xswiftc -warnings-as-errors for the plain and lid-simulation builds, passed at 953b4b0.
  • Full suite at 953b4b0, as /usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with a bash -c wrapper inside the lock that logged the time and inode before it ran that swift test under /usr/bin/script with exec, 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, and pmset -g log shows 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").

  • [P1] F1. The app and the agent could enforce different cutoffs (Sources/Insomnia/Core/SessionManager.swift:659). Fixed in 98dba00. checkConfigFile accepted 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. init no 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.
  • [P1] F2. A second copy's audio callback could rewrite the owner's journal (Sources/Insomnia/Core/SessionManager.swift:464). Fixed in 9a24156. SessionManager.init registered 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, and LaunchGate calls 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.
  • [P1] F3. An early end could leave a session that the next launch resumed (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 only sleepDisabledByUs, 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 when sleepDisabledByUs is clear, so the write that fails stops the resume.
  • [P2] F4. The tick retried an unreadable journal every second (Sources/Insomnia/Core/SessionManager.swift:732). Fixed in 9e86194. Only a busy lock set the retry delay. The tick now waits recoveryRetryDelay after 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.
  • Doc. The recovery drawing's description still said the backup check leaves a valid session alone (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_bounded the conflict was resolved with main's version in full. The supervisor owns the command's limit and its SIGTERM by jobspec, waits run on the SECONDS clock, 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 old run_bounded went with it. Both sides' test fixture steps are kept.

b72a4f6 then rewrites this branch's run_read to 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 by wait_for_job on the SECONDS clock, and signal_job sends SIGTERM and then SIGKILL by jobspec after checking that the job is the read. Its output goes through a .backstop.<pid>.<call>.out file that the call removes. The first bounded call of a run removes leftover .pid, .rc and .out files 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 at disablesleep 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 ran date +%s for its 60 s watchdog. A group signal that killed that date left 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 that date slowed 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.
  • testALiveCommandIsReportedOnTimeWhenEveryPollIsSlow failed 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's SECONDS, so the wait forks nothing but /bin/sleep and a group signal finds no date to 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 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 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.

  • Focused regressions at 7bab185 covered 228 tests across AppAliveLockTests, ConfigTests, CutoffAgreementTests, JournaledSessionEndTests, LaunchGateTests, ReconcileLidGatingTests, ReconcileTests, RecoverySafetyTests, StoreTests, UndoClearFailureTests, and 59 RecoveryScriptTests cases. The script cases are Backstop: the supervisor owns each undo command's limit and signal #50's six new tests and its changed 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.
  • Focused regressions after the CI fixes, at be4b4c8 and again at b96f62a, covered 75 tests (AppNapTests 16, JournaledSessionEndTests 5, ReconcileTests 48, and the six RecoveryScriptTests that use the changed fake sudo). All passed both times, and the tests that ran match the selected list. At b96f62a the two tests that count signals each passed five runs of five.
  • New tests. CutoffAgreementTests (F1) run the real backstop.sh, its tools patched to fakes (25% on battery power, thermal level 3 or 0), beside a SessionManager on one home folder. Each case checks that the app's FloorRules and 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.
  • Mutation checks, each made on the round's code and reverted. Each one failed at least one of the new tests. For F1, the file's cutoffs not taken into the app failed 7 of the 8 CutoffAgreementTests, a missing file not written back failed all 8, and Settings applying a cutoff before its save failed 10 assertions. For F2, registering the device callback in init failed. For run_read, signalling the read by pid through $KILL failed. 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.
  • Static checks. /bin/bash -n (3.2.57) on all seven scripts, shellcheck scripts/*.sh (0.10.0) and xmllint on the SVG passed at 7bab185, and the scripts have not changed since. scripts/check-lid-simulation-gate.sh, which runs swift build -c release -Xswiftc -warnings-as-errors for the plain and lid-simulation builds, passed at 7bab185, be4b4c8 and b96f62a.
  • Full suite at 7bab185. It ran as /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.
  • Full suite at be4b4c8, as /usr/bin/lockf -k /private/tmp/insomnia-fable/swifttest.lock swift test --skip UIStatusTests --skip UIStartupTests --skip KeychainStoreTests, with a bash -c wrapper inside the lock that logged the time and inode before it ran that swift test with exec. 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 -k and 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).
  • Full suite at b96f62a, with the same lockf -k command and bash -c wrapper. 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, and pmset -g log shows no sleep or wake during the run.

Hosted CI was not used to clear anything in this round.

Decisions

  • A separate AppAliveLock type rather than RecoveryLock: this lock is held for the process lifetime, needs 0600, and another PR touches RecoveryLock permissions.
  • The probe uses lockf's command form, not the fd-9 form the recovery lock uses, so nothing in the script's own file descriptors can keep the alive lock.
  • Thermal threshold 3 was checked against libkern/OSThermalNotification.h: 0 nominal, 1 moderate, 2 heavy, 3 trapping, 4 sleeping, and ProcessInfo.ThermalState.critical starts at trapping, which is where FloorRules ends the session.
  • The over-maximum label and the Days tooltip use every unit floored to the minute ("1d", "1d30m", "23h59m"), so what is shown can be typed back in. 24 hours still reads "1d", as on the preset chips and the Settings maximum row.
  • Only a config.json without configVersion is 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.
  • A failing pmset -g batt with the floor on still ends the session, and so does a failing ioreg after pmset showed no battery row. A read that cannot show there is no battery is not proof of a desktop.
  • The missing-row check reads the I/O Registry with 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.
  • endFloor is 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.)
  • A second copy quits rather than staying inert. An inert copy would need a menu of its own to be quit, and would look like a working app that does nothing. The notification is awaited before the quit, so it still shows after the process is gone.
  • An undecodable config.json is moved aside, not decoded key by key. A key-by-key decode would keep the good values, but it cannot help a file with a syntax error, and it leaves the bad file for the backstop to read. The rename covers every case and matches Recovery: move an unreadable session.json aside instead of keeping it forever #23's handling of session.json.
  • A read that ignores SIGTERM gets SIGKILL and runs without the lock. An undo command never gets SIGKILL and keeps the lock while it is alive. A read is unprivileged and changes nothing, so killing it cannot leave the machine half changed; a killed sudo pmset could.
  • Since b72a4f6 a read is the shell's own job, signalled by jobspec, as Backstop: the supervisor owns each undo command's limit and signal #50 does for the app binary. No pid is read back from a file, so a signal cannot reach a process that reused the read's pid.
  • session.json goes before the undo only for a valid session the run ends. An expired session stays while the journal is dirty, as before, because a relaunched app ends an expired session instead of resuming it. A valid session over a malformed journal is removed too, and the journal is kept as evidence; the app then refuses transactions until the journal decodes, as it does for any unreadable journal. (Correction in round 27: a valid session over a journal the app does not load is now kept, with the journal, and the run stops before it ends the session. Removing it let a run end a session whose changes it could not undo, Greptile 4219151866.)
  • The cancelled session is recorded in its own file, 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.
  • When ended-session.json cannot be written, the journal holds the record (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/base64 prints the same line as Swift's base64EncodedString().
  • When state.json cannot take the record either, the end goes in a new file beside it rather than being inferred from 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.
  • A record aside is removed only when session.json is gone or cmp reports 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 when cmp cannot read session.json.
  • For 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% (+30 reads 30) errs toward ending the session. (Until round 23, which removed the shell reader.)
  • When the removal and all three records fail, sleep is still restored. Its journal entry stays so the journal reads dirty: the next run exits 1 again and uninstall stops, instead of reporting a clean machine.
  • A reboot does not end a session by itself, and the docs now say so; the app and the agent do not compare a boot identifier. The spec has the app resume a valid session at launch when its journaled hold still reads 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.
  • performEnd is 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 in end() is what left the pending end behind after an adoption.
  • An end refused for an unreadable journal is retried on the same timer as one refused for a held lock. A person can repair the file at any time, and before this nothing would send the next end request.
  • The descriptor check is a separate fake mode with a 30 s limit, not a readiness handshake. The read answers as soon as lsof returns, so the long limit costs nothing on a normal run, and the SIGTERM test keeps the 1 s limit it needs.
  • The tick's wait after a refused transaction reuses 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.
  • A config.json the app rejects and cannot move aside blocks sessions rather than the app publishing its effective cutoffs for the agent, and the shell does not learn the decoder's rules. Since round 23 the shell reads the file through the app's own decoder, and a rejected file gives the agent the defaults, so the block still keeps a session from running on cutoffs the agent does not share. (Correction in round 25: a rejected file no longer gives the agent the defaults during a session. It gives the cutoffs the journal records for the session, 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.
  • The file decides the two cutoffs, and the app takes a hand edit in rather than writing its own values over it. The agent cannot see the app's memory, and a person who edits the file expects the edit to hold. Only the end floor and thermal rule are taken in, so a hand edit cannot change any other setting behind the user's back.
  • A Settings change to a cutoff is saved before it applies. Applying first is what let a failed save leave the app alone on a new cutoff. Other settings still apply at once, because the agent does not read them.
  • A missing file whose write-back fails blocks sessions only when the app's cutoffs differ from the agent's defaults. When they match, both sides already agree, and refusing would stop sessions on a full disk with nothing gained.
  • The agent reads the cutoffs by running the installed binary's decode-only mode, not a second parser. One decoder cannot disagree with itself, while a shell parser has to follow the decoder's rules (duplicate and escaped keys, rounding, rejection of the whole object) case by case, which is how rounds 19 to 22 kept finding gaps. The mode answers before AppKit starts, takes no lock and writes nothing, so it answers while the UI is hung, and it needs nothing the Mac does not already have.
  • When the binary cannot answer, the agent enforces the strictest cutoffs (95%, thermal rules on), not the defaults. It cannot tell which cutoffs the app enforces then, and keeping sleep disabled on a floor below the app's is the worse error. A rejected file gives the defaults, as a missing one does, because those are what the app falls back to. (Correction in round 25: a rejected or missing file now gives the cutoffs the journal records for the session, and the defaults only when it records none. A journal value the app does not write gives the strictest values.) (Correction in round 27: when the binary cannot answer, the agent reads the journal's record itself, once the journal passes its check. The strictest values remain for a record the app does not write, and for no record while config.json is there and only the binary failed on it.)
  • The recovery lock file holds the last record because it is the one file that already exists and that the agent and the app both open for writing while they hold the lock. A new file needs a folder that takes one, which the reviewer's case denied. The record is written in place and the file is never unlinked or replaced, so its inode and the lock it carries stay the same; a test checks the inode under every case. Content that is not one whole record ends whatever session.json holds, because a write cut short cannot say which session it ended, and ending a session errs on the safe side.
  • The journal records the cutoffs for the session, rather than the agent falling back to 95% and thermal rules on whenever config.json is rejected or missing. The strictest values would end a session with the floor off and turn thermal rules on that the user turned off. The record is written under the recovery lock before the change takes effect, and a config.json save that fails puts the old record back. (Correction in round 27: when the record cannot be put back either, the session ends before the lock is released.) It goes through the same binary with the app's own reader, so there is no second parser.
  • The log folder is the place for a record when Application Support takes no new file. It is outside that folder, so a deny ACL or chmod there 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.
  • Reconcile replaces session.json before it holds sleep instead of refusing every resume after a crash. An end that recorded nothing could not remove session.json, so a file that cannot be replaced may hide such an end; one that can be replaced resumes as before. Refusing every crash resume would end the sessions the spec resumes after a crash or at login.
  • The audio callback is registered once the copy holds the alive lock, rather than guarded in each handler. A refused copy then has no path to the owner's journal, and one registration point is easier to check than a guard in every callback.
  • The extension allowance uses the manager's clock, exposed as 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.
  • The Days pill keeps its 30-day entry ceiling so a raised maxDuration still works; the refusal happens at commit.
  • The "Battery and thermal" section of Settings was left alone; another PR owns it.

Not covered

  • Hardware. The release-validation rows for this PR are "Not run": force-quit end within a minute, battery end with the app stopped via 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 with SleepDisabled 1), and, from round 21, the same end in a folder that takes no new file and a rejected endFloor of -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 rounded endFloor texts, and --agent-cutoffs run by hand on an installed app. They need supervised runs on a real Mac and cannot be done in CI.
  • A session the backstop ended while the lid is closed (countdown paused) is dropped at the app's next transaction: lid open, a floor change, an end, or the deadline. Between those the menu bar still shows the countdown.
  • With the lid closed the tick does not run, so a hand edit to a cutoff reaches the agent first and the app at its next transaction. An agent run that read config.json just before a change uses the old values for that run.
  • If the backstop probes before the app has ever launched, lockf creates .app.alive with the default mode; the script's umask is handled in another PR.
  • The drawing in docs/assets/recovery-flow.svg still 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.
  • A healthy valid-session run now executes pmset -g batt and notifyutil once a minute, and since round 23 also the app binary's --agent-cutoffs when config.json exists.
  • Only the pmset commands and the reads (pmset -g batt, ioreg when needed, notifyutil and, 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.
  • A read that survives SIGKILL, stuck in the kernel, is left behind when the run moves on. It holds no lock, and the run logs it.
  • When nothing can record an end, the run restores sleep, keeps sleepDisabledByUs and exits 1 every minute. Since round 21 this needs a folder that takes no new file (chmod 555 on 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 what pmset did: 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 reads pmset -g. A 0 ends the session. A 1 (the restore failed, or another program holds disablesleep 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 with SleepDisabled 1 still resumes it: nothing on disk tells that end from a crash. testAllRefusedFullRepairResumesTheDocumentedLimit in 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.)
  • A session whose journaled hold reads 0 at launch is ended, not resumed, whatever undid the hold: the agent's unrecorded end, a pmset disablesleep 0 run by hand while Insomnia was stopped, or a start that died between its journal write and its pmset. A pmset -g that fails at that point, or prints no SleepDisabled line, ends the session too.
  • A record that cannot be removed after its session.json is gone stays on disk until a person removes it. It matches no later session. The backstop logs it on every run and uninstall names it. A journal endedSession that 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.
  • With launch at login on, a session whose deadline is still ahead at a reboot can resume when the app starts first. It then lasts until its deadline or a cutoff. Since round 19 that resume also needs SleepDisabled 1 after 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.
  • A record aside that a person removes or edits stops ending its session, as for ended-session.json. An app older than round 21 does not read records aside; the app and the scripts ship in one bundle and are installed together.
  • The app's tick lists the folder for records aside once a second, but only while session.json is present and neither ended-session.json nor the journal records its end. Every agent run expands one glob for them.
  • endFloor limits, 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.9999999999999999 is 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's endFloor, 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.
  • The app-binary read (round 23). When the installed binary is missing, declares another 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.app after 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.)
  • Option 2 is not in this PR. install.sh still grants NOPASSWD pmset -a disablesleep 1, and SleepGuard still runs it with sudo -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.
  • AppDelegate has no unit test. Its quit guard is one line on aliveLock.isHeld, and a release-validation row covers a second copy on hardware.
  • A second copy still runs SessionManager.init before 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.
  • A config.json that turns bad and cannot be moved during a session is caught at the next transaction or lid-open tick. If the app is stopped before one, the agent enforces the file's endFloor and thermalRules until 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.)
  • The StoreTests FIFO test for the end record passes with or without the app's regular-file check, because Data(contentsOf:) on macOS 26 refuses a FIFO without opening it. The check names the real cause and does not rely on that.
  • The config.json FIFO test also passes without the backstop's check, because plutil on macOS 26 refuses a FIFO without blocking. The test pins the outcome. The check does not rely on plutil. Since round 23 the backstop opens config.json itself, as the binary's standard input, so its regular-file check is what keeps a run from opening a FIFO there; that was not re-tested with the check removed.
  • Each regular-file check and the open after it are two steps. A file replaced by a FIFO between them still blocks the run.
  • Removing the temp files before writing has no test. Their names carry the run's PID, which a test cannot know in advance.
  • The app writes the same log with FileHandle(forWritingTo:), which blocks on a FIFO with no reader (checked on macOS 26). Log.append is unchanged from main, and this PR does not change it.
  • Round 25 limits. Content in the recovery lock file that is not one whole record (a write cut short, a hand edit, more than 1 MiB, a file that cannot be read whole) ends whatever session.json holds, also a crash the app would otherwise resume, until session.json is gone; the next Start empties it. A lock file owned by another user or replaced by a symlink is never read or written, so it records nothing; the symlink case is tested, the other owner is not, because the test cannot create a file of another owner without root. While a symlink is at that path, uninstall stops before it removes anything (the backstop exits 75), as it did before round 25. An ended-session.json or a record aside that cannot be read ends nothing while it cannot be read, and the agent keeps it. A Settings change to a cutoff during a session is refused while an agent run or a transaction holds the recovery lock, and Settings asks to try again. With config.json rejected or missing, a sessionCutoffs value 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.
  • Round 27 limits, not waived. The journal check refuses some journals the app loads, none of which the app writes: a key twice in one object at any depth, an integer written as 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, --force included, 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. Besides sleep, 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 one cat in bounded(), on a line only a bounded sudo reaches, 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.
  • An end recorded nowhere (round 27 assessment, not waived). bec766b writes the end into the recovery lock file when neither folder takes a new file, which closes the reviewer's round 25 case. The end is still recorded nowhere when the lock file takes no write either: it is not a regular file this user owns, or the write fails, as on a full disk. Two histories then leave the same files. In the first, the app crashes during a valid session: session.json stays, the journal holds sleepDisabledByUs and sessionCutoffs, and pmset reads SleepDisabled 1. In the second, the agent ends the session for a cutoff, can record that nowhere, restores sleep and keeps sleepDisabledByUs; another program then sets disablesleep 1, and a person repairs every file. A relaunch reads the same session.json bytes, the same journal and SleepDisabled 1 in both, so it resumes both. The log ~/Library/Logs/Insomnia/insomnia.log can 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 .1 copy, 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.
  • Hosted CI time (round 27, not waived). The workflow stops swift test 1200 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.
  • Round 29 limits, not waived. The log record of item 1 narrows the end recorded nowhere but does not close it:
    • On a full disk or after an I/O error the log takes no line either, and the round 27 assessment below still holds for that case: a relaunch after a full repair cannot tell that end from a crash, and resumes both.
    • A log that cannot be read or is over 64 MiB holds no record for any reader, and a session.json over 64 KiB is never recorded there.
    • Lines the app writes without the recovery lock never rotate insomnia.log, so it can pass 1 MiB until a line written under the lock rotates it. A rotation also waits while .1 cannot be read, or while session.json cannot be read and .1 holds any record.
    • A run whose read-back fails counts the end as recorded nowhere; the next run uses a whole line already in either log, or appends another. A record cannot be removed without rewriting the log, so it stays until rotation drops it after its session.json is gone.
    • A record matches bytes, not a session: a session.json written later with the same bytes reads as ended. A line of that form that a person writes into the log ends a session with those bytes.
    • An app or agent older than round 29 does not read the log record.
    • The journal check still refuses the journals listed under item 2, which the app loads but never writes. For them the agent keeps the session and its sleep hold, past the deadline too while the app is crashed or hung, until the app or a person rewrites the file.
    • In three cases (config.json there while the binary cannot answer and the journal has no record, a sessionCutoffs value 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.lockRecordWriteLimitForTesting is a DEBUG-only seam that release builds compile out. install.sh still finds its folder with a bare dirname, 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.
    • No local full run passed on 277b62a. Both failed only in cases that ran while the Mac slept with its lid closed ("Round 29 verification"), and a clean full run on an awake Mac is still owed. The one full run on f2298fe failed the same way, in two uninstall cases that ran across a Maintenance Sleep or in the first seconds after the wake and passed 3 of 3 alone ("f2298fe verification").
    • The lock file record cut short now counts as the end of the session whose bytes it starts with (f2298fe, "Greptile on 277b62a" above). A stale one can end a later session whose record starts the same way; that takes a start stopped between writing its session.json and emptying the lock file, or a person. A stop before the first byte of the record is written still records nothing.
  • Hosted CI time (round 29, not waived). Hosted CI on 47bdc6c failed: the watchdog stopped the run with 1114 cases started and 1113 passed, no failure seen, and the release and lid checks skipped. The watchdog's 1200 iterations took 1291 s of wall time. Round 29's concurrent tables take about 87.5 s less locally, but the current 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. The two-job split under round 29 is a proposal for the parent, and a longer watchdog is the other choice. Neither was done, and the projections are not a hosted run.
  • Hosted CI on 277b62a (round 29, not waived) failed the same way: run 37853967819 (job 113573559199) started 1140 cases and passed 1139, with no failed case or assertion and no skip, and the watchdog stopped the 1140th, 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.
  • Round 31 limits, not waived. Round 31 narrows four of the round 29 lines above; those lines stay as they were written:
    • The journal check refuses fewer journals than round 29's line says (R30-4 under "Round 31"). It still refuses, and the app never writes: a key the app reads twice in an object it reads (other than sessionCutoffs), a whole number written with a fraction or an exponent that a Double does not hold exactly (9007199254740993.0), a number a Double rounds (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.
    • Round 29's three 95% cases are narrower. When the binary cannot answer for config.json, the agent reads the file itself. A sessionCutoffs value 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).
    • A stale record cut short (round 29's last line) no longer reaches a later session: a start settles the lock file before it writes session.json (R30-3). An end stopped before any record of it counts still records nothing, and a relaunch resumes that session (R30-6).
    • A test now covers the start refusal while the lock file cannot be read, through the DEBUG-only seam Store.lockReadErrnoForTesting, which release builds compile out.
    • A start that fails does not put back lock file content over 1 MiB, or an unreadable lock file it emptied with no earlier session.json. Neither ended a session. A start whose last step cannot empty the lock file rolls back with the earlier session's record kept, so starts fail at that step until the file takes writes.
    • In a log this user may only write to, the last byte cannot be read, so every writer puts a newline first and the log gains blank lines. install.sh's AGENT_PROGRAM line (the LaunchAgent's refusal line) now differs from main's.
    • A sessionCutoffs written 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.
    • No test stops a real writer partway in the app's process other than through a file size limit, and none stops the agent between > and its write. No test covers a failed cleanup specific to the log tail rule.
  • Hosted CI on f2298fe passed (round 31 correction). Run 37865392744 (job 113610779091) started and ended 1427 cases: 1417 passed, 10 UIStatusTests cases were skipped and none failed. The release and lid steps passed. The three hosted CI lines above, and round 29's split proposal, predicted that one job would be stopped again; that run did not bear them out. They stay as written. The workflow is still main's.
  • Round 33 limits, not waived. Round 33 narrows some of the round 31 lines above; those lines stay as they were written:
    • The log lock holds only among the writers Insomnia ships. A process that appends to insomnia.log without the lock can still break a record at the end of the file. SECURITY.md and docs/spec.md say so.
    • A record the app cannot lock within 2 s, or the agent within 5 s, is not written, so that end counts only through another record. The app keeps at most 64 KiB of ordinary lines that waited and drops the oldest whole lines past that. The agent writes a line it could not lock to standard error instead of the log.
    • The journal check now refuses only these forms the app loads, none of which the app 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. For keys written twice, numbers the app rounds, and numbers or escapes plutil cannot parse where the app skips them, round 31's list above no longer holds.
    • The scripts copy the decoder's rule for keys written twice (the first copy, also after escapes such as a Kelvin sign) as Foundation behaves on the test machine. Foundation ships with macOS, so a later macOS that reads such keys otherwise would differ from the scripts until they change. A journal a script publishes drops later copies and keys the app does not read, as the app's own save does.
    • With no record, the agent enforces 95% with thermal rules on while config.json is over 8 MiB, holds an Int64 on which Foundation stops the app, or is not read within 30 s.
    • A lock file whose reads fail three times, 0.1 s apart, counts as the end of whatever session.json holds.
    • R30-6 is unchanged: an end recorded nowhere, and an end stopped before any record of it counts.
    • 224 cases were not run locally on 62d57c5 ("Round 33 verification"), and the hardware rows stay Not run.
  • Hosted CI on a41341c failed (round 33, not waived). Run 37895264686 (job 113705065958) started 1151 cases and passed 1150, with no failed case, assertion or skip. The watchdog stopped the 1151st, 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):

  • A second Insomnia that found the alive lock held kept trying every 2 s for the life of the process. d245a97 replaced this: such a copy now quits at launch.
  • Every transaction but an end checks, under the recovery lock right after reading the journal, whether session.json is still there while a session is held in memory. If it is gone, the backstop ended the session while the app was stopped, hung, or not holding the lock; the app then ends on its side from the journal (new 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.
  • backstop.sh checks the JSON type of endFloor and thermalRules with plutil -type, so a string "30" or "false" falls back to the default as it does in the app. It reads endFloor before pmset, 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).
  • The allowance label and the Days tooltip show every unit floored to the minute ("Up to 1d30m").

Second review round:

  • An older stock config.json whose default was the 3-day preset no longer leaves bare Enter refusing. When the user never set the ceiling, a default above 24 hours moves to the largest preset left under it (4 hours if none is), and presets above it are dropped, since Settings refuses to add them.
  • Current builds write configVersion 2 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 pmset read 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.json fixed-path rule (a script may stop its own child with the builtin kill) merged without a conflict and is unchanged.

Two follow-ups bring this PR's code in line with main. e8c749d routes the six bare rm and mv calls this PR added to backstop.sh through $RM and $MV. a676549 has the app open ended-session.json and session.json for the end record only when they are regular files, as #23's Store.read does.

Main was merged a fifth time in cadfe0c for #21, with no conflicts. Its new tmuxNudgePressesEnter key decodes like the others. With #36 every test run has a temporary INSOMNIA_HOME; this PR's tests build their paths from TempHome or 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:

Earlier review rounds

Codex (gpt-6.1-sol, xhigh) reviewed 4e08846. All three findings are fixed.

  • [P1] An incomplete early end left a valid session that a relaunch resumed (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 waits recoveryRetryDelay (30 s) after a failed lock wait instead of starting a 10 s wait and a log line every second.
  • [P1] A stuck read probe could block every later cutoff (scripts/backstop.sh:243). Fixed in 327682a. The battery and thermal reads run through a new run_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 keep run_bounded and hold the lock while they run, as before. fd 9 is the only descriptor the scripts open for recovery.
  • [P2] The fake app's alive lock ran out after 30 s (Tests/InsomniaTests/RecoveryScriptTests.swift:2270). Fixed in 7707b3e. holdAliveLock takes the lock with AppAliveLock in the test process, and each test releases it in its defer. holdLock is 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.

  • [P1] A session.json that could not be removed let a relaunch resume the session (scripts/backstop.sh:499, the same finding as Greptile comment 4168633620 at line 501). Fixed in b9cd044. Greptile's exit 1 was 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 to ended-session.json and 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, keeps sleepDisabledByUs journaled, and exits 1 with a log line that names the file.
  • [P2] The retry-delay test assumed the first failed lock wait finished within 3.2 s (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 calls noticeAgentEnd itself: one tick with the lock held, ticks within the delay after its release, and the tick at the delay that ends the session.
  • Greptile [P0] Timeout signals bypassed $KILL (comment 4168633632, scripts/backstop.sh:272). Fixed in 5a9b68e. The SIGTERM and SIGKILL in run_read and the SIGTERM in run_bounded, bare since main, all go through "$KILL". The fixture's fake kill passes -TERM and -KILL on to /bin/kill only for a pid in one of the run's own .backstop.*.pid files.

Codex (gpt-6.1-sol, xhigh) and Greptile reviewed 5a9b68e. All four findings are fixed.

  • [P1] Adopting an end the agent had completed could leave new sessions blocked for good (Sources/Insomnia/Core/SessionManager.swift:322). Fixed in 9bbfdc9. An end refused for an unreadable journal set pendingEnd with no retry, and only end() cleared it. After the tick adopted the agent's end, every Start was refused. performEnd, which end(), the adoption and reconcile all run, now settles the pending end when it restores or leaves the restore to an armed agent. It clears pendingEnd and 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.
  • [P2] The descriptor check raced the fixture's 1 s read limit (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.
  • [P2] Login ordering did not guarantee that a session ends after a reboot (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).
  • Greptile [P2] An end record that could not be removed survived a purge (comment 4169235256, scripts/backstop.sh:537). Fixed in 42f369d. The backstop and the app's Store.deleteSession log a record they cannot remove, naming the file. uninstall.sh removes 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's remove_owned, which names it on stderr and exits 1.

Codex (gpt-6.1-sol, xhigh) and Greptile reviewed cbe8cdf. All four findings are fixed.

  • [P1] A missing battery row bypassed the cutoff on laptops too (scripts/backstop.sh:501). Fixed in 3c302ba. When pmset -g batt has no InternalBattery line, the backstop now runs ioreg -r -c AppleSmartBattery -d 1, the service PowerMonitor.classify checks. No service means a desktop and no battery rule, as before. A service with "ExternalConnected" = Yes keeps 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.
  • [P1] Whole-valued JSON floats set the wrong floor (scripts/backstop.sh:460). Fixed in 3c302ba. config_int now 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, as Config.normalizeFloors does, so 200 is 95 and -5 is off.
  • Greptile [P1] An idle instance masked a crash (comment 4170418679, Sources/Insomnia/InsomniaApp.swift:84). Fixed in d245a97. A new LaunchGate takes 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 and AppAliveLock.acquireEventually are gone.
  • Greptile [P1] An invalid version marker erased the settings (comment 4170418685, Sources/Insomnia/Model/Config.swift:140). Fixed in 6d99b72. configVersion counts by its presence, as Store.configHasVersion already 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 to config.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.sh keeps the copies and --purge removes them.

Codex (gpt-6.1-sol, xhigh) reviewed cadfe0c. Both findings are fixed.

  • [P1] A FIFO end record could block recovery indefinitely (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 new end_recorded compares 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 in record_end. The app and record_end write the record by rename, so anything else at that path is stale and is removed with rm, 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's Store.readData. log() appends only to a regular file and drops the line otherwise. The PID-named temp files in record_end and the state publish are removed before they are written. The alive lock probe needs no check, because lockf with -t 0 fails at once on a FIFO (exit 73, checked on macOS 26).
  • [P1] The relaunch tests unset the process-wide INSOMNIA_HOME (Tests/InsomniaTests/RecoveryScriptTests.swift:611 and :739). Fixed in 12417c4. A new pointInsomniaHome(at:) in TestSupport moves the variable and returns a closure that puts back the value it had, or ProcessTestHome.root if it had none. Both tests call that closure in a defer. RecoveryScriptTests.tearDown now 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. TempHome already restores ProcessTestHome.root when destroyed, and the other INSOMNIA_HOME uses are child process environments (extraEnvironment) or a dictionary passed to Paths.fromEnvironment. No code in Tests or Sources calls unsetenv now.

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 keptSessionFile replaced the two flags next to this branch's configNotice. Both are kept. #47's testSessionWithOffsetDatesIsReadLikeTheApp expected 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 in exclusive, where main's writeOwedEdits() now runs before this branch's adoptAgentEnd(). 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.

  • [P2] SECURITY.md said holding the liveness lock left the deadline as the only cutoff (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.

  • [P1] The backstop could disable cutoffs that the app enabled after rejecting config.json (scripts/backstop.sh:599). Fixed in 91e6d3c, on the app side as the maintainer asked; the shell still reads only endFloor and thermalRules. 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, rejectedConfigFile holds 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 reason settingsFileRejected. 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.

  • [P1] A failed replacement write cleared the config rejection (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.

  • First commit. RecoveryScriptTests, against a patched private copy of backstop.sh with fake pmset -g batt and notifyutil, 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, endFloor 30, 0 and bad values, thermal levels 0 to 4 with unreadable output and thermalRules off, the reason in the restore log line, and --force running 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.
  • Second commit. Tests for the config type check, the floor-off read, hung reads, the lock retry, the legacy-default migration, the full-unit label, and four manager tests for a session the agent ended. Focused runs: AppAliveLockTests 7, ConfigTests 10, DurationInputTests 20, ReconcileTests 21, RecoveryScriptTests 84, all passing. The menu bar suites' two new assertions were compile-checked locally and run in hosted CI.
  • Second review round. ConfigTests for an old config whose default was the 3-day preset, trimmed lists, a ceiling set by hand, a current file keeping 30 days, and the one-time write-back.
  • Codex round on 4e08846. Script tests for a thermal read that ignores SIGTERM (no fd 9, lock free after the run), early ends with saved brightness, a failing or hung pmset, and a relaunch after a partial early end; each fails with its backstop change reverted. A ReconcileTests case shows the tick's lock waits wait for the retry delay.
  • Review of 884ff01. Immutable session.json cases in RecoveryScriptTests, ReconcileTests and RecoverySafetyTests (the record is written, honored by later runs and the app, removed once the file goes, and a stale one ends nothing), and $KILL asserts in the timeout tests. Mutation checks covered the record, its use by each reader, the neither case, the bare kill calls and the tick delay.
  • Review of 5a9b68e. Pending-end settling after an adopted end, the unreadable-journal retry, the lsof fd 9 check under a 30 s limit, and purge handling of the record. Mutation checks covered each fix.
  • Review of cbe8cdf. A battery missing from pmset with and without AppleSmartBattery, whole-valued and clamped endFloor floats, 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.
  • Review of cadfe0c. FIFOs at ended-session.json, config.json and the log are never opened, and both relaunch tests restore INSOMNIA_HOME. Mutation checks covered end_recorded, log() and the home restore.
  • Review of bda4dce. ConfigLoadTests for a locked, undecodable config.json at Start, after deletion, during a session, at launch, and a movable one during a session. Mutation checks covered each path.
  • Review of 91e6d3c. ConfigLoadTests for the failed replacement write (0c4a3b7) and the deleted rejected file (7a05ff6), each failing without its fix, and the Lid close: leave meeting apps running, mute by default, update old configs once #49 merge's one-time updates. A focused run at 7a05ff6 selected 162 tests, none from KeychainStoreTests. 161 passed, and the one failure was fixed in 3e5dd12.

Full suites, each as /usr/bin/lockf /private/tmp/insomnia-fable/swifttest.lock swift test with 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.

commit tests failures
first commit 507 0 (113 s, nothing skipped)
second commit 466 0 (105 s)
merge of main 487 0
4e08846 490 0
884ff01 504 0
5a9b68e 512 0
87fe970, merge of main 585 0
cbe8cdf 592 0
cadfe0c 662 0
d76d4e5, after merging #47 676 1 test (3 assertions), fixed in 4a13512
4a13512 676 0
906b5d2, after merging #38 690 0
91e6d3c 695 0
3e5dd12 1077 2, both main tests that de6131d lines up
de6131d 1077 0 (672 s)

At 3e5dd12, #19's testBackstopTightensWhatAnOlderBuildLeftLoose and #22's testPassWaitingForTheJournalLeavesAPendingEndTheCleanup failed for the reasons in the merge notes above. The first full run at de6131d did not finish. testInstallUnloadsTheNewJobBeforeRollingBackWhenItsLoadIsUnconfirmed took 935 s and failed on its 5-second limit for the fake launchctl print, and pmset -g log shows 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/bash 3.2 syntax check and bash 4 grep, shellcheck scripts/*.sh, swift build -c release -Xswiftc -warnings-as-errors) and at de6131d (the release build and scripts/check-lid-simulation-gate.sh). actionlint and zizmor were not run here; this PR does not touch workflows.

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5 Tier: plus

Fix the shell’s negative-fraction check before merging; it can turn off a recorded battery cutoff.

Fix All in Claude CodeFindings

  1. P1 Rejected fraction disables battery cutoff ▶
  2. P2 Recovery instructions disagree ▶
Fix with agent prompt
### Issue 1
scripts/backstop.sh:877-884
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`.

### Issue 2
README.md:687-690
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.

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.

  • The recovery agent ends sessions when Insomnia is gone or a cutoff is reached.
  • Ended sessions stay ended when their session file cannot be removed.
  • The app and recovery agent use the same battery and heat cutoffs.
  • Sessions now default to 24 hours, and the UI explains the limit.

Prior acknowledgments: krishhgg accepted ending on a failed battery read with the floor enabled because that failure cannot prove the Mac has no battery. krishhgg deferred redrawing the recovery illustration. krishhgg retained 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]
Loading

Reviews (24) · Last reviewed commit: "Tests: the cut-short and bad-escape conf..." · Reviewed by Greptile

…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>
Comment thread Sources/Insomnia/InsomniaApp.swift Outdated
Comment thread scripts/backstop.sh
Comment thread Sources/Insomnia/Model/Config.swift
Comment thread scripts/backstop.sh Outdated
Comment thread scripts/backstop.sh Outdated
Comment thread scripts/backstop.sh Outdated
Comment thread Sources/Insomnia/UI/MenuBarModel.swift Outdated
Comment thread README.md Outdated
…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>
Comment thread Sources/Insomnia/Model/Config.swift Outdated
Comment thread Sources/Insomnia/Model/Config.swift Outdated
krishhgg and others added 8 commits October 2, 2026 00:25
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>
Comment thread scripts/backstop.sh Outdated
Comment thread scripts/backstop.sh Outdated
krishhgg and others added 3 commits October 2, 2026 12:43
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>
Comment thread scripts/backstop.sh Outdated
krishhgg and others added 5 commits October 2, 2026 15:16
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>
Comment thread Sources/Insomnia/InsomniaApp.swift Outdated
Comment thread Sources/Insomnia/Model/Config.swift Outdated
krishhgg and others added 7 commits October 2, 2026 18:24
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>
krishhgg and others added 4 commits October 7, 2026 17:39
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>
Comment thread scripts/backstop.sh Outdated
…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>
Comment thread scripts/backstop.sh Outdated
…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>
Comment thread scripts/backstop.sh
Comment thread Sources/Insomnia/Core/SessionManager.swift Outdated
Comment thread scripts/backstop.sh Outdated
krishhgg and others added 8 commits October 8, 2026 07:02
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>
Comment thread Sources/Insomnia/Store/Store.swift Outdated
…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>
krishhgg and others added 2 commits October 8, 2026 22:52
…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>
Comment thread Sources/Insomnia/Store/OwnerOnly.swift Outdated
krishhgg and others added 6 commits October 9, 2026 03:44
… 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>
Comment thread scripts/backstop.sh
Comment on lines +877 to +884
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code Fix in Conductor Fix in Codex

Comment thread README.md
Comment on lines +687 to +690
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

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.

Fix in Claude Code Fix in Conductor Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant