Skip to content

fix(macos): fail cleanly without GUI services#4238

Open
rfoust wants to merge 1 commit into
aethersdr:mainfrom
rfoust:codex/macos-startup-gui-service-error
Open

fix(macos): fail cleanly without GUI services#4238
rfoust wants to merge 1 commit into
aethersdr:mainfrom
rfoust:codex/macos-startup-gui-service-error

Conversation

@rfoust

@rfoust rfoust commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

On macOS, launching AetherSDR from a restricted process without normal GUI service access causes QCocoa/AppKit to abort in HIServices during QApplication construction, generating a crash report. This change adds a macOS-only SIGABRT guard scoped strictly to QApplication initialization. It emits a clear startup error and exits with status 1, then restores the exact previous signal action immediately after successful initialization so runtime aborts retain their normal behavior. A regression test covers the real std::abort() path and restoration of the previous handler.

Constitution principle honored

Principle XI — Fixes Are Demonstrated. The restricted launch was reproduced before the change with exit status 134 and a macOS crash report. The fixed build exits normally with status 1, prints the startup diagnostic, and produces no new crash report. Automated coverage also verifies the abort path and successful disarming of the guard.

Test plan

  • Local build passes (cmake --build build)
  • Behavior verified on a real radio if applicable (not applicable — failure occurs before radio access)
  • Existing tests pass (CI) — awaiting PR/CI execution
  • Reproduction steps documented if user-reported bug

Validation performed:

  • Configured and built the complete project with Ninja and RelWithDebInfo.
  • Passed mac_startup_abort_guard_test, including a child process invoking std::abort().
  • Passed the full local test suite; three GUI tests blocked by the restricted shell passed when rerun with normal macOS GUI access. One existing test remained intentionally skipped.
  • Reproduced the original restricted launch at exit status 134 with a generated .ips crash report.
  • Repeated the restricted launch after the fix: exit status 1, clear startup diagnostic, and no new crash report after the reporter delay.
  • Verified a normal macOS launch successfully initialized the automation bridge.
  • Passed git diff --check.
  • Passed the strict engine-boundary check with no blockers.

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls — use nested-JSON-under-one-key
    (Principle V)
  • Code is clean-room — not decompiled, disassembled, or
    reverse-engineered from a proprietary binary (Principle IV)
  • All meter UI uses MeterSmoother (AGENTS.md convention) — no meter code changed
  • Documentation updated if user-visible behavior changed — no supported workflow changed; the startup diagnostic is self-contained
  • Security-sensitive changes reference a GHSA if applicable — not applicable

Generated with OpenAI Codex.

@rfoust
rfoust requested a review from a team as a code owner July 14, 2026 00:45
Copilot AI review requested due to automatic review settings July 14, 2026 00:45
@rfoust
rfoust requested a review from a team as a code owner July 14, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a macOS-specific startup safeguard that converts a SIGABRT occurring during QApplication construction (e.g., when GUI services are unavailable under a restricted launcher) into a clean, user-facing startup error with exit status 1, then restores the original SIGABRT disposition immediately after successful initialization.

Changes:

  • Add MacStartupAbortGuard to temporarily install a SIGABRT handler during QApplication construction and restore the previous handler afterward.
  • Integrate the guard into src/main.cpp for macOS-only startup behavior.
  • Add a macOS-only regression test that validates both the guarded abort path and handler restoration.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/mac_startup_abort_guard_test.cpp New macOS-only regression test for abort handling + signal restoration.
src/main.cpp Wrap QApplication construction on macOS with the abort guard and add startup diagnostics.
src/MacStartupAbortGuard.h Declares the RAII guard used to temporarily override SIGABRT.
src/MacStartupAbortGuard.cpp Implements the async-signal-safe abort handler and restoration logic.
CMakeLists.txt Builds the guard source on Apple targets and adds the macOS-only test target.

Comment on lines +3 to +6
#include <csignal>
#include <cstdlib>
#include <cstdio>
#include <string>
Comment on lines +60 to +63
ssize_t bytesRead = 0;
while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) > 0) {
errorText.append(buffer, static_cast<std::size_t>(bytesRead));
}

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clean, well-scoped change. The guard does exactly what the summary claims: an async-signal-safe handler (write + _exit, both valid in signal context), armed strictly around QApplication construction, disarmed immediately after with the exact previous sigaction restored, and an RAII destructor backstop. The test genuinely exercises the real std::abort() path in a forked child and verifies the previous handler is restored. Conventions are fine — no settings/meter/RAII concerns, all files within scope, commits signed. Thanks for the thorough reproduction and the hardware-free regression test.

On the two Copilot notes: finding #1 is a false positive — it claims the read() loop checks errno == EINTR, but the loop has no errno reference at all, so <cerrno> is not needed by the current code. Finding #2 (retry on EINTR) is a reasonable defensive nit but very unlikely to fire here (see inline).

Non-blocking notes:

  • Test stderr-capture loop stops on any read() <= 0, including a hypothetical EINTR — see inline. Low priority; optional hardening.

🤖 aethersdr-agent · cost: $3.9704 · model: claude-opus-4-8

Comment on lines +60 to +61
ssize_t bytesRead = 0;
while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot flagged this as EINTR-flaky. It's a legitimate defensive pattern in general, but note it can't actually trigger in this test: the parent installs no SIGABRT/other signal handler before this read, and SIGCHLD's default disposition doesn't interrupt a blocking read(). So the current loop is correct as written.

If you want the belt-and-suspenders version anyway, retry on EINTR and add #include <cerrno> to the includes block:

    ssize_t bytesRead = 0;
    while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) != 0) {
        if (bytesRead < 0) {
            if (errno == EINTR) continue;
            break;
        }
        errorText.append(buffer, static_cast<std::size_t>(bytesRead));
    }

Re: Copilot's separate <cerrno> comment — that's a false positive against the current diff, since nothing here touches errno unless you adopt the retry above.

@jensenpat

Copy link
Copy Markdown
Collaborator

Holding for next weeks release.

@jensenpat
jensenpat marked this pull request as draft July 17, 2026 16:21
@jensenpat
jensenpat marked this pull request as ready for review July 19, 2026 03:23
@ten9876

ten9876 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

First-pass triage review (not exhaustive — surfacing blockers to keep this moving):

Blockers

None spotted in a first pass. The failure/exit path looks sound:

  • handleStartupAbort (src/MacStartupAbortGuard.cpp:11) uses only write() + _exit() — both async-signal-safe, which is exactly right for an abort raised deep inside AppKit/HIServices before Qt logging exists.
  • struct sigaction action {} zero-inits sa_flags/mask; the tight arm-before / disarm-after scoping around QApplication app(argc, argv) (src/main.cpp:180204) correctly preserves normal crash semantics for runtime aborts by restoring m_previousAction.
  • Placement is safe: the code preceding the guard only reads settings files, no GUI/QApplication access, so nothing GUI-touching runs unguarded.
  • Cleanly gated behind Q_OS_MAC / if(APPLE) in both main.cpp and CMake; non-macOS builds and the test target are unaffected. Double-disarm (explicit + destructor) is a safe no-op via the m_armed guard.

Secondary notes

  • Scope caveat: this only converts a genuine SIGABRT into a clean exit. The repro (exit 134) confirms SIGABRT on the tested OS, but if a future macOS routes the GUI-denied failure through __builtin_trap/os_crash (SIGILL/SIGTRAP) or a plain exit(), the guard won't cover it. Worth a one-line comment noting the SIGABRT-only assumption.
  • disarm() failing after a successful QApplication construction returns EXIT_FAILURE and kills an otherwise-healthy launch (src/main.cpp:200). sigaction with valid args is effectively never going to fail here, so this is theoretical — but bailing out of a working app on it is arguably harsher than logging and continuing.
  • The guard covers only the QApplication constructor; QSurfaceFormat/other GUI setup afterward would still crash normally if GUI services vanish there. Matches the observed repro, just noting the boundary.

Nothing here blocks; looks mergeable pending CI. Nice clean-room regression test covering the real std::abort() path and handler restoration.

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.

4 participants