Skip to content

Commit 5e21d22

Browse files
committed
Pre-spawn process worker in cli.py to avoid the fds_to_keep race
The first two commits made the macOS Tahoe segfault non-fatal by disabling the fork fallback, but every spawn after Textual booted still failed with `bad value(s) in fds_to_keep` — leaving the user with a "Process worker unavailable; falling back" popup and the slower in-process executor on every query. Root cause: `multiprocessing.spawn` snapshots the parent's open file descriptors at spawn time. Once Textual's `App.__init__` has registered its signal-handler pipes and event-loop FDs, several of those are not inheritable, and spawn aborts before the child even runs. The previous lazy creation in the idle scheduler always hit this race. Fix: spawn the worker in `cli.py` *before* `SSMSTUI(...)` is constructed. That's the latest moment at which the parent's FD set is still clean. The new `_prewarm_process_worker(runtime)` helper returns a live `ProcessWorkerClient`, or `None` if the worker is disabled / mocked / spawn raised — in which case we fall through to the lazy path inside the UI (with the in-process fallback on darwin). The pre-spawned client is passed into `SSMSTUI` via a new `process_worker_client` kwarg and stashed as `self._process_worker_client`. `ProcessWorkerLifecycleMixin` already returns the cached client first, so no other changes are needed. Verified on macOS Tahoe 26.4.1 / Python 3.14 / PostgreSQL over TLS: the popup no longer appears and queries run in the worker process.
1 parent b32fdee commit 5e21d22

2 files changed

Lines changed: 43 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/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)

0 commit comments

Comments
 (0)