Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ Most of the time you can just run `sqlit` and connect. If a Python driver is mis
| Database | Driver package | `pipx` | `pip` / venv |
| :---------------------------------- | :--------------------------- | :------------------------------------------------- | :------------------------------------------------- |
| SQLite | *(built-in)* | *(built-in)* | *(built-in)* |
| PostgreSQL / CockroachDB / Supabase | `psycopg2-binary` | `pipx inject sqlit-tui psycopg2-binary` | `python -m pip install psycopg2-binary` |
| PostgreSQL / CockroachDB / Supabase | `psycopg[binary]` | `pipx inject sqlit-tui 'psycopg[binary]'` | `python -m pip install 'psycopg[binary]'` |
| SQL Server | `mssql-python` | `pipx inject sqlit-tui mssql-python` | `python -m pip install mssql-python` |
| MySQL | `PyMySQL` | `pipx inject sqlit-tui PyMySQL` | `python -m pip install PyMySQL` |
| MariaDB | `mariadb` | `pipx inject sqlit-tui mariadb` | `python -m pip install mariadb` |
Expand Down
4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
# here; install with `pipx inject` or a custom derivation.
nixpkgsExtras = {
ssh = [ pyPkgs.sshtunnel pyPkgs.paramiko ];
postgres = [ pyPkgs.psycopg2 ];
cockroachdb = [ pyPkgs.psycopg2 ];
postgres = [ pyPkgs.psycopg ];
cockroachdb = [ pyPkgs.psycopg ];
mysql = [ pyPkgs.pymysql ];
duckdb = [ pyPkgs.duckdb ];
bigquery = [ pyPkgs.google-cloud-bigquery ];
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dynamic = ["version"]

[project.optional-dependencies]
all = [
"psycopg2-binary>=2.9.0",
"psycopg[binary]>=3.2.0",
"mssql-python>=1.1.0",
"PyMySQL>=1.1.0",
"mariadb>=1.1.0",
Expand All @@ -64,8 +64,8 @@ all = [
"osquery>=3.0.0",
"surrealdb>=1.0.0",
]
postgres = ["psycopg2-binary>=2.9.0"]
cockroachdb = ["psycopg2-binary>=2.9.0"]
postgres = ["psycopg[binary]>=3.2.0"]
cockroachdb = ["psycopg[binary]>=3.2.0"]
mssql = ["mssql-python>=1.1.0"]
mysql = ["PyMySQL>=1.1.0"]
mariadb = ["mariadb>=1.1.0"]
Expand Down
37 changes: 36 additions & 1 deletion sqlit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,32 @@ def _sane_tty() -> None:
pass


def _prewarm_process_worker(runtime: RuntimeConfig) -> Any | None:
"""Spawn the process worker before the Textual App is constructed.

`multiprocessing.spawn` collects the parent's open file descriptors at
spawn time. Textual's `App.__init__` registers signal-handler pipes
and other non-inheritable FDs, which cause spawn to abort with
`ValueError: bad value(s) in fds_to_keep` (see issue #189). Spawning
here — before the App is constructed — is the latest point at which
the FD set is still clean.

Returns the live client, or None if spawn fails or the worker is
disabled. On failure we fall through to the lazy path inside the UI;
on macOS that path raises and the in-process executor takes over.
"""
if not runtime.process_worker:
return None
if runtime.mock.enabled:
return None
try:
from sqlit.domains.process_worker.app.process_worker_client import ProcessWorkerClient

return ProcessWorkerClient()
except Exception:
return None


def _run_app(app: Any) -> int:
exit_code: int | None = None
handled_signals = [signal.SIGINT, signal.SIGTERM]
Expand Down Expand Up @@ -627,10 +653,14 @@ def main() -> int:
print(f"Error: {exc}")
return 1

# Spawn the worker before the Textual App is constructed; see
# _prewarm_process_worker for why this matters on macOS.
process_worker_client = _prewarm_process_worker(runtime)
app = SSMSTUI(
services=services,
startup_connection=startup_config,
exclusive_connection=exclusive_connection,
process_worker_client=process_worker_client,
)
exit_code = _run_app(app)
if exit_code != 0:
Expand Down Expand Up @@ -684,7 +714,12 @@ def main() -> int:
print(f"Error: {exc}")
return 1

app = SSMSTUI(services=services, startup_connection=temp_config)
process_worker_client = _prewarm_process_worker(runtime)
app = SSMSTUI(
services=services,
startup_connection=temp_config,
process_worker_client=process_worker_client,
)
return _run_app(app)

if args.command in {"connections", "connection"}:
Expand Down
2 changes: 2 additions & 0 deletions sqlit/domains/connections/app/install_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def _get_arch_package_name(package_name: str) -> str | None:
mapping = {
"psycopg2-binary": "python-psycopg2",
"psycopg2": "python-psycopg2",
"psycopg[binary]": "python-psycopg",
"psycopg": "python-psycopg",
"mssql-python": "python-mssql",
"PyMySQL": "python-pymysql",
"mysql-connector-python": "python-mysql-connector",
Expand Down
22 changes: 21 additions & 1 deletion sqlit/domains/connections/providers/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,19 @@ def execute_query(self, conn: Any, query: str, max_rows: int | None = None) -> t
"""Execute a query using cursor-based approach with optional row limit."""
cursor = conn.cursor()
cursor.execute(query)
# Multi-statement queries (e.g. "BEGIN; INSERT; SELECT") produce
# several result sets on drivers that expose them — psycopg v3
# leaves the cursor positioned on the first set (the BEGIN, which
# has no columns), so advance to the last one. psycopg2 and other
# drivers either don't implement nextset() or return None for
# single-statement queries; either way this loop terminates.
while True:
try:
more = cursor.nextset()
except Exception:
more = None
if not more:
break
if cursor.description:
columns = [col[0] for col in cursor.description]
if max_rows is not None:
Expand All @@ -511,7 +524,14 @@ def execute_non_query(self, conn: Any, query: str) -> int:
cursor = conn.cursor()
cursor.execute(query)
rowcount = int(cursor.rowcount)
conn.commit()
# When the connection is in autocommit mode, an explicit conn.commit()
# has driver-dependent semantics: psycopg2 makes it a no-op, but
# psycopg v3 actively ends any open transaction. Calling commit()
# here would unconditionally close out an explicit BEGIN before the
# caller's INSERT/ROLLBACK gets a chance to run. Skipping it when
# autocommit is on preserves the intent in both worlds.
if not getattr(conn, "autocommit", False):
conn.commit()
return rowcount


Expand Down
16 changes: 8 additions & 8 deletions sqlit/domains/connections/providers/cockroachdb/adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""CockroachDB adapter using psycopg2 (PostgreSQL wire-compatible)."""
"""CockroachDB adapter using psycopg v3 (PostgreSQL wire-compatible)."""

from __future__ import annotations

Expand All @@ -18,7 +18,7 @@


class CockroachDBAdapter(PostgresBaseAdapter):
"""Adapter for CockroachDB using psycopg2 (PostgreSQL wire-compatible)."""
"""Adapter for CockroachDB using psycopg v3 (PostgreSQL wire-compatible)."""

@property
def name(self) -> str:
Expand All @@ -30,11 +30,11 @@ def install_extra(self) -> str:

@property
def install_package(self) -> str:
return "psycopg2-binary"
return "psycopg[binary]"

@property
def driver_import_names(self) -> tuple[str, ...]:
return ("psycopg2",)
return ("psycopg",)

@property
def supports_stored_procedures(self) -> bool:
Expand All @@ -47,8 +47,8 @@ def supports_triggers(self) -> bool:

def connect(self, config: ConnectionConfig) -> Any:
"""Connect to CockroachDB database."""
psycopg2 = self._import_driver_module(
"psycopg2",
psycopg = self._import_driver_module(
"psycopg",
driver_name=self.name,
extra_name=self.install_extra,
package_name=self.install_package,
Expand All @@ -61,7 +61,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args: dict[str, Any] = {
"host": endpoint.host,
"port": port,
"database": endpoint.database or "defaultdb",
"dbname": endpoint.database or "defaultdb",
"user": endpoint.username,
"password": endpoint.password,
"connect_timeout": 10,
Expand All @@ -88,7 +88,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args["sslpassword"] = tls_key_password

connect_args.update(config.extra_options)
conn = psycopg2.connect(**connect_args)
conn = psycopg.connect(**connect_args)
# Enable autocommit to avoid transaction issues
conn.autocommit = True
return conn
Expand Down
21 changes: 13 additions & 8 deletions sqlit/domains/connections/providers/postgresql/adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""PostgreSQL adapter using psycopg2."""
"""PostgreSQL adapter using psycopg (v3)."""

from __future__ import annotations

Expand All @@ -18,7 +18,12 @@


class PostgreSQLAdapter(PostgresBaseAdapter):
"""Adapter for PostgreSQL using psycopg2."""
"""Adapter for PostgreSQL using psycopg (v3).

psycopg3 delegates TLS to libpq instead of bundling libssl, which avoids
pulling Security.framework into the parent process at import time on
macOS — one of the crash classes covered by issue #189.
"""

@property
def name(self) -> str:
Expand All @@ -30,16 +35,16 @@ def install_extra(self) -> str:

@property
def install_package(self) -> str:
return "psycopg2-binary"
return "psycopg[binary]"

@property
def driver_import_names(self) -> tuple[str, ...]:
return ("psycopg2",)
return ("psycopg",)

def connect(self, config: ConnectionConfig) -> Any:
"""Connect to PostgreSQL database."""
psycopg2 = self._import_driver_module(
"psycopg2",
psycopg = self._import_driver_module(
"psycopg",
driver_name=self.name,
extra_name=self.install_extra,
package_name=self.install_package,
Expand All @@ -50,7 +55,7 @@ def connect(self, config: ConnectionConfig) -> Any:
raise ValueError("PostgreSQL connections require a TCP-style endpoint.")
connect_args: dict[str, Any] = {
"connect_timeout": 10,
"database": endpoint.database or "postgres",
"dbname": endpoint.database or "postgres",
}
if endpoint.host:
connect_args["host"] = endpoint.host
Expand All @@ -75,7 +80,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args["sslpassword"] = tls_key_password

connect_args.update(config.extra_options)
conn = psycopg2.connect(**connect_args)
conn = psycopg.connect(**connect_args)
# Enable autocommit to avoid "transaction aborted" errors on failed statements
conn.autocommit = True
return conn
Expand Down
33 changes: 33 additions & 0 deletions sqlit/domains/process_worker/app/process_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@

from __future__ import annotations

import faulthandler
import os
import pickle
import sys
import tempfile
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from multiprocessing.connection import Connection
from pathlib import Path
from typing import Any


def _open_worker_log() -> Any | None:
"""Open the worker log file, honoring SQLIT_WORKER_LOG when set.

The parent process may attach this worker to a Textual-managed pipe;
libpq notice writes or unexpected stderr output would then SIGPIPE the
child. Redirecting both streams to a real file avoids that and gives a
place to land C-level fault tracebacks for future diagnosis.
"""
override = os.environ.get("SQLIT_WORKER_LOG")
if override:
path = Path(override).expanduser()
else:
path = Path(tempfile.gettempdir()) / "sqlit-worker.log"
try:
path.parent.mkdir(parents=True, exist_ok=True)
return path.open("a", buffering=1)
except OSError:
return None

from sqlit.domains.connections.domain.config import ConnectionConfig
from sqlit.domains.connections.providers.catalog import get_provider
from sqlit.domains.connections.providers.config_service import normalize_connection_config
Expand Down Expand Up @@ -493,6 +518,14 @@ def _get_provider(self, db_type: str) -> Any | None:

def run_process_worker(conn: Connection) -> None:
"""Process entrypoint for query execution."""
log_file = _open_worker_log()
if log_file is not None:
sys.stdout = log_file
sys.stderr = log_file
try:
faulthandler.enable(file=log_file)
except (RuntimeError, ValueError):
pass
state = _WorkerState(conn=conn)
try:
while True:
Expand Down
8 changes: 8 additions & 0 deletions sqlit/domains/process_worker/app/process_worker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ def _maybe_fallback_start(self, error: Exception) -> None:
raise error
if os.name != "posix" or sys.platform.startswith("win"):
raise error
# macOS (especially Tahoe 26+) aborts in the child whenever a forked
# process touches CoreFoundation / Security.framework / os_log —
# which happens inside psycopg, getaddrinfo, and many other stdlib
# paths. Forking here causes hard segfaults (issue #189), so we
# surface the spawn failure and let the caller fall back to
# in-process execution.
if sys.platform == "darwin":
raise error
try:
self._start_with_context(get_context("fork"))
except Exception:
Expand Down
7 changes: 7 additions & 0 deletions sqlit/domains/shell/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,16 @@ def __init__(
runtime: RuntimeConfig | None = None,
startup_connection: ConnectionConfig | None = None,
exclusive_connection: bool = False,
process_worker_client: Any | None = None,
):
super().__init__()
self.services = services or build_app_services(runtime or RuntimeConfig.from_env())
# A pre-spawned worker handed in from cli.py, before Textual's
# FD set was contaminated. ProcessWorkerLifecycleMixin already
# checks self._process_worker_client first, so seeding it here
# short-circuits the lazy spawn path.
if process_worker_client is not None:
self._process_worker_client = process_worker_client
from sqlit.core.connection_manager import ConnectionManager

self._connection_manager = ConnectionManager(self.services)
Expand Down
16 changes: 8 additions & 8 deletions tests/fixtures/cockroachdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
pytest.skip("CockroachDB is not available")

try:
import psycopg2
import psycopg
except ImportError:
pytest.skip("psycopg2 is not installed")
pytest.skip("psycopg is not installed")

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database="defaultdb",
dbname="defaultdb",
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand All @@ -60,10 +60,10 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
cursor.execute(f"CREATE DATABASE {COCKROACHDB_DATABASE}")
conn.close()

conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database=COCKROACHDB_DATABASE,
dbname=COCKROACHDB_DATABASE,
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand Down Expand Up @@ -139,10 +139,10 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
yield COCKROACHDB_DATABASE

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database="defaultdb",
dbname="defaultdb",
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand Down
Loading