Skip to content

fix(autostart): a launch agent left half-written reads as Open at log… - #1079

Open
TryWorld2026 wants to merge 1 commit into
yetone:mainfrom
TryWorld2026:fix/autostart-atomic-write
Open

TryWorld2026 wants to merge 1 commit into
yetone:mainfrom
TryWorld2026:fix/autostart-atomic-write

Conversation

@TryWorld2026

@TryWorld2026 TryWorld2026 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

fix(autostart): a launch agent left half-written reads as Open at login off, not on

What is wrong

Two things about Open at login, on the three file-backed platforms.

A half-written record read as on. enabled() was os.Stat(record()) == nil
on darwin and on the XDG platforms, so a launch agent or .desktop that a
crash, a full disk or a kill left short — the header but no
Label/ProgramArguments, no Type/Exec — read as on. launchd loads no
job that names neither its label nor a program, and the desktop starts no entry
that is not an application entry or names nothing to run, so such a record
refuses to load and magpie never opens at login however long the file sits
there. Settings said it would.

The writes were the kind that can leave exactly that file behind. Four
os.WriteFile calls wrote the record in place, truncating first, so a crash or
a kill mid-write left a truncated one — the very thing the read then reported as
on.

What changed

  • The four os.WriteFile calls become edit.WriteAtomic, which writes a temp
    file beside the record and renames it over. A file's mode is preserved, as
    os.WriteFile's mode argument only ever applied at creation anyway, so
    Android's follow-up Chmod 0o700 still applies and an existing record keeps
    its mode. One nuance: a record being created for the first time on Android
    goes through 0o644 for the instant before that Chmod, where the old call
    created it at 0o700.
  • enabled() reads the record and asks for the keys the platform needs before
    it will start anything: <key>Label</key> with the label magpie writes, plus
    ProgramArguments naming the program, on darwin; ^Type=Application$ plus
    ^Exec= on the XDG platforms. A record short of either reads as off, not
    on.

Windows's registry record has neither shape and is a separate change.

Semantic change (Autostart)

  • Before: a truncated launch agent or .desktop read as on, and the writes
    that created one were not atomic.
  • After: a record missing the keys its platform needs reads as off, and the
    write cannot leave a truncated record behind.
  • Reference: docs/subsystems/README.md owns this subsystem; the autostart
    package is not documented in a page of its own, so there is no reference to
    correct here. Implementation: enabled, enable and refresh in
    internal/autostart/autostart_darwin.go, autostart_other.go and
    autostart_android.go.

Verification

Both new checks are named TestSetThenTruncatedRecordReadsAsOff, one per
platform file. Neither runs on Windows — the darwin one is behind its filename
and the XDG one behind !darwin && !windows && !android — so this box cannot
execute them as they stand. What I did instead: in a scratch build, with the
platform's own implementation selected and the other ones out of the way, the
logic under test is plain file-and-regex and runs anywhere, so it ran here.

  • darwin, branch code: PASS. Base code (os.Stat): FAIL with
    a launch agent with no ProgramArguments reads as on and
    an empty launch agent reads as on.
  • XDG/other, branch code: PASS. Base code (os.Stat): FAIL with
    a desktop entry with no Exec reads as on and an empty desktop entry reads as on.

The scratch build was a throwaway worktree, removed afterwards; neither this
branch nor any other was changed to make them run.

  • go vet on the package and the three platform GOOS builds
    (darwin, linux, windows) pass, as does go test -race ./internal/autostart
    on Windows; gofmt is clean on LF-normalized copies, since the worktree is CRLF
    under core.autocrlf.
  • CI's ubuntu and macos jobs are what execute these two on their own platforms.
  • Not exercised against a real macOS login or a real desktop session: this is a
    Windows box. What the two platforms do with a record that names no program is
    stated from their documented behaviour, not from a record watched being
    refused.

Not in this PR

Windows's Open at login has the same class of lie — off reported success
when it had done nothing, and a key it cannot read is not read as off — with a
different cause and a different fix. It is the companion PR,
fix/autostart-honest-errors.

…in off, not on

The Mac's launch agent, the Linux .desktop and the Android boot script were
written with a plain write, which empties the file before it fills it, so a
crash or a full disk part way through left a record that is empty or half
there. launchd refuses a job that names neither its own label nor a program,
and the desktop starts no entry with no Exec, so magpie simply never opened
at login. Enabled() on the Mac and on Linux asked only whether the file was
there, so Settings kept showing the switch on; and Refresh(), which the app
runs at every start, rewrites only a record its ProgramArguments regexp can
still find a program in, which a half-written one does not have. Nothing
repaired it and nothing said so, so the user was left believing a switch
that did nothing.

The four writes of these records now go through edit.WriteAtomic, which
writes a temp file beside the record and renames it over: a write lands
whole or not at all, and one cut short leaves the previous record, still
loadable, where it was. No import cycle: internal/edit brings in only
internal/steady and internal/filememo.

enabled() on the Mac and on Linux now asks whether the record is one that
starts magpie — the launch agent names both its label and its program, the
desktop entry has both its Type and its Exec — instead of whether the file
is there. A record a write cut short now reads as off, so the switch stops
saying magpie opens at login when it does not, and the user sees the off
that Refresh() cannot repair. A record magpie did not write is still left
alone by Refresh() (TestRefreshOlderLaunchAgent), which is unchanged:
refresh() rewrites what it can parse, and enabled() reports off for what it
cannot. Windows' Run key is not touched here.

TestSetThenTruncatedRecordReadsAsOff is new, once for the Mac
(autostart_darwin_test.go) and once for Linux (autostart_other_test.go),
where the record is written whole and then left as a write cut short leaves
it: the head, stopping before the program it names, and the same record
emptied. Against the old code, whose check was os.Stat, the Mac one fails
with "a launch agent with no ProgramArguments reads as on" and the Linux one
with "a desktop entry with no Exec reads as on", each on both records. With
the change both pass, and TestSet, TestRefreshOlderLaunchAgent and
TestLaunchAgentLetsTheRelaunchLive are unchanged.

go vet, the windows, darwin and linux builds and test compiles, and gofmt
on the seven files (LF-normalized first, since the worktree is CRLF) pass.
Not run on a Mac or with plutil: this is a Windows box, so the darwin and
linux behaviour is checked by GOOS=darwin and GOOS=linux builds plus their
test compiles, and the two new predicates were run here against the bytes
those platforms write from a scratch harness, which showed the old os.Stat
check reading all three records as on. That harness is not committed.
@TryWorld2026
TryWorld2026 force-pushed the fix/autostart-atomic-write branch from 8ebcf88 to 70e015a Compare October 7, 2026 01:00

This branch has not been deployed

No deployments
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