informix-db/pyproject.toml
Ryan Malloy 4af1c90c6f Failed statements bricked the connection; cancelled acquires starved the
pool (2026.08.31.2)

Two more found by fuzzing rather than by users, outside the type system.
Both are ordinary-path bugs a happy-path test cannot reach.

A failed statement was never released. Successful DML sent PREPARE ->
EXECUTE -> RELEASE; failing DML sent PREPARE -> EXECUTE and stopped. The
statement stayed allocated server-side, collided with the next PREPARE,
and every subsequent call on that connection returned a nonsense error
(-255 "Not in transaction" under autocommit, -285 otherwise) whose
offset pointed back at the FAILED sql rather than the new statement.

In practice: one duplicate-key violation bricked the connection. An
INSERT tripping a unique constraint is about the most routine error an
application can hit — every insert-if-not-exists pattern makes them —
and afterwards nothing on that connection worked.

The docstring on the parameterised path already described this hazard
and guarded the parameter-ENCODING failure; it never covered EXECUTE
itself failing. Fixed in all three paths (DML, parameterised DML, and
SELECT — whose fetch loop had the same gap, and whose GC-time finalizer
only covers scrollable cursors).

Cancelling a pool acquire leaked the connection permanently.
asyncio.to_thread cannot interrupt its worker, so a task cancelled while
blocked in pool.acquire() left the worker to finish and hand back a
connection nobody owned — checked out, never returned, one slot gone per
occurrence until the pool was dead.

Not theoretical for anything serving HTTP: a client disconnect cancels
the request task, and under load those cancellations land precisely
while waiting for a connection. The pool dies one slot at a time, only
under load, and PoolTimeoutError points nowhere near the cause.
acquire() now shields the inner future and returns whatever the worker
produced if the caller went away; add_done_callback fires immediately on
an already-resolved future, so the "worker finished just before the
cancel" race is the same code path rather than a second one.

The harness covered three things the type fuzzer cannot see: fetch
batching (NFETCH is a 4096-BYTE budget, so batch edges move with row
width — counts 0..1025 across three schema widths, every fetch style
required to agree), error recovery (eight error classes, repeated, each
followed by a known-good query on the same cursor and a fresh one), and
concurrency (threads sharing one connection, pooled threads that fail
before releasing, transaction isolation across checkouts, async
cancellation). Every worker reads back a value only it supplied, so a
crossed wire fails on data even when nothing raises.

Two harness false positives worth recording: Informix silently truncates
over-long strings (a literal SQL insert does the same, so the driver
matches the server), and it rejects a bare `?` in a projection — a cast
is required.

356/356 integration on 15, 14.10 and 12.10 (was 326).
2026-08-31 17:13:43 -06:00

112 lines
3.8 KiB
TOML

[project]
name = "informix-driver"
version = "2026.08.31.2"
description = "Pure-Python driver for IBM Informix IDS — speaks the SQLI wire protocol over raw sockets. No CSDK, no JVM, no native libraries."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.10"
keywords = ["informix", "database", "sqli", "db-api", "pep-249", "asyncio", "async"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: AsyncIO",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Database",
"Topic :: Database :: Front-Ends",
"Typing :: Typed",
]
dependencies = []
[project.urls]
Homepage = "https://informix-driver.warehack.ing"
Documentation = "https://informix-driver.warehack.ing"
Source = "https://git.supported.systems/warehack.ing/informix-db"
Issues = "https://git.supported.systems/warehack.ing/informix-db/issues"
Changelog = "https://git.supported.systems/warehack.ing/informix-db/src/branch/main/CHANGELOG.md"
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/informix_db"]
[tool.hatch.build.targets.sdist]
# Defense in depth: exclude operator-private and dev-only artifacts from the sdist
# (the wheel doesn't ship these by default, but the sdist would).
# See ~/.claude/rules/python.md for the full pre-publish PII audit playbook.
exclude = [
"CLAUDE.md", # operator-private context
".env", ".env.local", ".env.*",
".mcp.json", # may contain local filesystem paths
"build/", # decompiled JDBC, downloaded JARs
"audits/",
"docs/**", # protocol notes / decision log / captures — go to GitHub for the depth
"docs-site/**", # Astro/Starlight site sources — published at informix-driver.warehack.ing
"tests/reference/**", # Java reference client — spike infra
"tests/benchmarks/.results/**", # pytest-benchmark cache (gitignored, but still ships unless excluded)
".pytest_cache/", ".ruff_cache/", ".mypy_cache/",
"dist/", "*.egg-info/",
]
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long — handled by formatter
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B011"] # allow assert False in tests
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
asyncio_mode = "auto" # pytest-asyncio: auto-detect ``async def`` tests
addopts = [
"-ra", # short summary for non-passing
"--strict-markers",
"--strict-config",
"-m", "not integration and not benchmark", # default: unit-only. Override with: pytest -m integration / -m benchmark
]
markers = [
"integration: requires a running Informix container (docker compose up); skipped by default",
"benchmark: pytest-benchmark performance test; skipped by default. Run with `make bench`.",
]
[dependency-groups]
dev = [
"pytest-asyncio>=1.3.0",
"pytest-benchmark>=5.2.3",
]