fix(macos): fail cleanly without GUI services#4238
Conversation
There was a problem hiding this comment.
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
MacStartupAbortGuardto temporarily install aSIGABRThandler duringQApplicationconstruction and restore the previous handler afterward. - Integrate the guard into
src/main.cppfor 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. |
| #include <csignal> | ||
| #include <cstdlib> | ||
| #include <cstdio> | ||
| #include <string> |
| ssize_t bytesRead = 0; | ||
| while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) > 0) { | ||
| errorText.append(buffer, static_cast<std::size_t>(bytesRead)); | ||
| } |
There was a problem hiding this comment.
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 hypotheticalEINTR— see inline. Low priority; optional hardening.
🤖 aethersdr-agent · cost: $3.9704 · model: claude-opus-4-8
| ssize_t bytesRead = 0; | ||
| while ((bytesRead = read(pipeFds[0], buffer, sizeof(buffer))) > 0) { |
There was a problem hiding this comment.
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.
|
Holding for next weeks release. |
|
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:
Secondary notes
Nothing here blocks; looks mergeable pending CI. Nice clean-room regression test covering the real |
Summary
On macOS, launching AetherSDR from a restricted process without normal GUI service access causes QCocoa/AppKit to abort in HIServices during
QApplicationconstruction, generating a crash report. This change adds a macOS-onlySIGABRTguard scoped strictly toQApplicationinitialization. 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 realstd::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
cmake --build build)Validation performed:
RelWithDebInfo.mac_startup_abort_guard_test, including a child process invokingstd::abort()..ipscrash report.git diff --check.Checklist
docs/COMMIT-SIGNING.md)AppSettingscalls — use nested-JSON-under-one-key(Principle V)
reverse-engineered from a proprietary binary (Principle IV)
MeterSmoother(AGENTS.md convention) — no meter code changedGenerated with OpenAI Codex.