Skip to content

Commit 0f46bee

Browse files
authored
Merge pull request #207 from brentrager/fix/macos-tahoe-fork-segfault
2 parents 555bc01 + 71c3635 commit 0f46bee

6 files changed

Lines changed: 216 additions & 1 deletion

File tree

sqlit/cli.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,32 @@ def _sane_tty() -> None:
177177
pass
178178

179179

180+
def _prewarm_process_worker(runtime: RuntimeConfig) -> Any | None:
181+
"""Spawn the process worker before the Textual App is constructed.
182+
183+
`multiprocessing.spawn` collects the parent's open file descriptors at
184+
spawn time. Textual's `App.__init__` registers signal-handler pipes
185+
and other non-inheritable FDs, which cause spawn to abort with
186+
`ValueError: bad value(s) in fds_to_keep` (see issue #189). Spawning
187+
here — before the App is constructed — is the latest point at which
188+
the FD set is still clean.
189+
190+
Returns the live client, or None if spawn fails or the worker is
191+
disabled. On failure we fall through to the lazy path inside the UI;
192+
on macOS that path raises and the in-process executor takes over.
193+
"""
194+
if not runtime.process_worker:
195+
return None
196+
if runtime.mock.enabled:
197+
return None
198+
try:
199+
from sqlit.domains.process_worker.app.process_worker_client import ProcessWorkerClient
200+
201+
return ProcessWorkerClient()
202+
except Exception:
203+
return None
204+
205+
180206
def _run_app(app: Any) -> int:
181207
exit_code: int | None = None
182208
handled_signals = [signal.SIGINT, signal.SIGTERM]
@@ -843,10 +869,14 @@ def main() -> int:
843869
print(f"Error: {exc}")
844870
return 1
845871

872+
# Spawn the worker before the Textual App is constructed; see
873+
# _prewarm_process_worker for why this matters on macOS.
874+
process_worker_client = _prewarm_process_worker(runtime)
846875
app = SSMSTUI(
847876
services=services,
848877
startup_connection=startup_config,
849878
exclusive_connection=exclusive_connection,
879+
process_worker_client=process_worker_client,
850880
)
851881
exit_code = _run_app(app)
852882
if exit_code != 0:
@@ -907,7 +937,12 @@ def main() -> int:
907937
print(f"Error: {alert_error}")
908938
return 1
909939

910-
app = SSMSTUI(services=services, startup_connection=temp_config)
940+
process_worker_client = _prewarm_process_worker(runtime)
941+
app = SSMSTUI(
942+
services=services,
943+
startup_connection=temp_config,
944+
process_worker_client=process_worker_client,
945+
)
911946
return _run_app(app)
912947

913948
if args.command in {"connections", "connection"}:

sqlit/domains/process_worker/app/process_worker.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,39 @@
22

33
from __future__ import annotations
44

5+
import faulthandler
6+
import os
57
import pickle
8+
import sys
9+
import tempfile
610
import threading
711
import time
812
from collections import deque
913
from dataclasses import dataclass, field
1014
from multiprocessing.connection import Connection
15+
from pathlib import Path
1116
from typing import Any
1217

18+
19+
def _open_worker_log() -> Any | None:
20+
"""Open the worker log file, honoring SQLIT_WORKER_LOG when set.
21+
22+
The parent process may attach this worker to a Textual-managed pipe;
23+
libpq notice writes or unexpected stderr output would then SIGPIPE the
24+
child. Redirecting both streams to a real file avoids that and gives a
25+
place to land C-level fault tracebacks for future diagnosis.
26+
"""
27+
override = os.environ.get("SQLIT_WORKER_LOG")
28+
if override:
29+
path = Path(override).expanduser()
30+
else:
31+
path = Path(tempfile.gettempdir()) / "sqlit-worker.log"
32+
try:
33+
path.parent.mkdir(parents=True, exist_ok=True)
34+
return path.open("a", buffering=1)
35+
except OSError:
36+
return None
37+
1338
from sqlit.domains.connections.domain.config import ConnectionConfig
1439
from sqlit.domains.connections.providers.catalog import get_provider
1540
from sqlit.domains.connections.providers.config_service import normalize_connection_config
@@ -493,6 +518,14 @@ def _get_provider(self, db_type: str) -> Any | None:
493518

494519
def run_process_worker(conn: Connection) -> None:
495520
"""Process entrypoint for query execution."""
521+
log_file = _open_worker_log()
522+
if log_file is not None:
523+
sys.stdout = log_file
524+
sys.stderr = log_file
525+
try:
526+
faulthandler.enable(file=log_file)
527+
except (RuntimeError, ValueError):
528+
pass
496529
state = _WorkerState(conn=conn)
497530
try:
498531
while True:

sqlit/domains/process_worker/app/process_worker_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ def _maybe_fallback_start(self, error: Exception) -> None:
6363
raise error
6464
if os.name != "posix" or sys.platform.startswith("win"):
6565
raise error
66+
# macOS (especially Tahoe 26+) aborts in the child whenever a forked
67+
# process touches CoreFoundation / Security.framework / os_log —
68+
# which happens inside psycopg, getaddrinfo, and many other stdlib
69+
# paths. Forking here causes hard segfaults (issue #189), so we
70+
# surface the spawn failure and let the caller fall back to
71+
# in-process execution.
72+
if sys.platform == "darwin":
73+
raise error
6674
try:
6775
self._start_with_context(get_context("fork"))
6876
except Exception:

sqlit/domains/shell/app/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,16 @@ def __init__(
100100
runtime: RuntimeConfig | None = None,
101101
startup_connection: ConnectionConfig | None = None,
102102
exclusive_connection: bool = False,
103+
process_worker_client: Any | None = None,
103104
):
104105
super().__init__()
105106
self.services = services or build_app_services(runtime or RuntimeConfig.from_env())
107+
# A pre-spawned worker handed in from cli.py, before Textual's
108+
# FD set was contaminated. ProcessWorkerLifecycleMixin already
109+
# checks self._process_worker_client first, so seeding it here
110+
# short-circuits the lazy spawn path.
111+
if process_worker_client is not None:
112+
self._process_worker_client = process_worker_client
106113
from sqlit.core.connection_manager import ConnectionManager
107114

108115
self._connection_manager = ConnectionManager(self.services)

tests/unit/test_cli_prewarm.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for the pre-spawn helper in cli.py and the SSMSTUI kwarg plumbing."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock, patch
6+
7+
from sqlit.cli import _prewarm_process_worker
8+
from sqlit.shared.app.runtime import MockConfig, RuntimeConfig
9+
10+
11+
def test_prewarm_skipped_when_worker_disabled() -> None:
12+
runtime = RuntimeConfig(process_worker=False)
13+
with patch("sqlit.domains.process_worker.app.process_worker_client.ProcessWorkerClient") as klass:
14+
result = _prewarm_process_worker(runtime)
15+
assert result is None
16+
klass.assert_not_called()
17+
18+
19+
def test_prewarm_skipped_when_mock_enabled() -> None:
20+
runtime = RuntimeConfig(process_worker=True, mock=MockConfig(enabled=True))
21+
with patch("sqlit.domains.process_worker.app.process_worker_client.ProcessWorkerClient") as klass:
22+
result = _prewarm_process_worker(runtime)
23+
assert result is None
24+
klass.assert_not_called()
25+
26+
27+
def test_prewarm_returns_client_when_enabled() -> None:
28+
runtime = RuntimeConfig(process_worker=True)
29+
sentinel = MagicMock(name="process-worker-client")
30+
with patch(
31+
"sqlit.domains.process_worker.app.process_worker_client.ProcessWorkerClient",
32+
return_value=sentinel,
33+
):
34+
result = _prewarm_process_worker(runtime)
35+
assert result is sentinel
36+
37+
38+
def test_prewarm_swallows_spawn_errors() -> None:
39+
"""A failing pre-spawn must not break startup; lazy path handles fallback."""
40+
runtime = RuntimeConfig(process_worker=True)
41+
with patch(
42+
"sqlit.domains.process_worker.app.process_worker_client.ProcessWorkerClient",
43+
side_effect=ValueError("bad value(s) in fds_to_keep"),
44+
):
45+
result = _prewarm_process_worker(runtime)
46+
assert result is None
47+
48+
49+
def test_ssmstui_stashes_prewarmed_worker() -> None:
50+
"""A client passed via the kwarg should land on self._process_worker_client.
51+
52+
ProcessWorkerLifecycleMixin returns that attribute first, so this is
53+
what makes the pre-spawn actually short-circuit the lazy path.
54+
"""
55+
from sqlit.domains.shell.app.main import SSMSTUI
56+
from tests.ui.mocks import MockConnectionStore, MockSettingsStore, build_test_services, create_test_connection
57+
58+
services = build_test_services(
59+
connection_store=MockConnectionStore([create_test_connection("test-db", "sqlite")]),
60+
settings_store=MockSettingsStore({}),
61+
)
62+
sentinel = MagicMock(name="process-worker-client")
63+
app = SSMSTUI(services=services, process_worker_client=sentinel)
64+
assert app._process_worker_client is sentinel
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for the macOS fork-fallback guard and the worker log redirect."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from sqlit.domains.process_worker.app.process_worker import _open_worker_log
10+
from sqlit.domains.process_worker.app.process_worker_client import ProcessWorkerClient
11+
12+
13+
def _make_client_without_init() -> ProcessWorkerClient:
14+
"""Build a bare ProcessWorkerClient instance without running __init__.
15+
16+
__init__ spawns a real subprocess; we only want to exercise the
17+
in-process branching logic in _maybe_fallback_start.
18+
"""
19+
return ProcessWorkerClient.__new__(ProcessWorkerClient)
20+
21+
22+
def test_fork_fallback_disabled_on_darwin(monkeypatch: pytest.MonkeyPatch) -> None:
23+
"""On macOS the fallback path must re-raise instead of forking."""
24+
monkeypatch.setattr("sys.platform", "darwin")
25+
client = _make_client_without_init()
26+
27+
error = ValueError("bad value(s) in fds_to_keep")
28+
with pytest.raises(ValueError, match="fds_to_keep"):
29+
client._maybe_fallback_start(error)
30+
31+
32+
def test_fork_fallback_skips_non_fds_errors(monkeypatch: pytest.MonkeyPatch) -> None:
33+
"""Errors unrelated to fds_to_keep should always re-raise verbatim."""
34+
monkeypatch.setattr("sys.platform", "linux")
35+
client = _make_client_without_init()
36+
37+
error = ValueError("totally unrelated message")
38+
with pytest.raises(ValueError, match="totally unrelated"):
39+
client._maybe_fallback_start(error)
40+
41+
42+
def test_worker_log_honors_env_override(
43+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
44+
) -> None:
45+
"""SQLIT_WORKER_LOG should redirect the worker log to a custom path."""
46+
target = tmp_path / "subdir" / "worker.log"
47+
monkeypatch.setenv("SQLIT_WORKER_LOG", str(target))
48+
49+
log_file = _open_worker_log()
50+
assert log_file is not None
51+
try:
52+
log_file.write("hello\n")
53+
finally:
54+
log_file.close()
55+
56+
assert target.exists()
57+
assert "hello" in target.read_text()
58+
59+
60+
def test_worker_log_returns_none_on_unwritable_path(
61+
monkeypatch: pytest.MonkeyPatch,
62+
) -> None:
63+
"""If the log path can't be opened, the worker should still boot."""
64+
# A path whose parent can't be created (root-owned, non-existent).
65+
monkeypatch.setenv("SQLIT_WORKER_LOG", "/proc/definitely/not/writable/here.log")
66+
67+
log_file = _open_worker_log()
68+
assert log_file is None

0 commit comments

Comments
 (0)