Skip to content

fix(cbinding): return caller-owned string from gosqlx_version; add Python CI - #847

Merged
ajitpratap0 merged 2 commits into
mainfrom
fix/cbinding-version-double-free
Oct 21, 2026
Merged

ajitpratap0 merged 2 commits into
mainfrom
fix/cbinding-version-double-free

Conversation

@ajitpratap0

Copy link
Copy Markdown
Owner

Summary

Bug: since #331, gosqlx_version() has returned a sync.Once-cached C string. Its doc comment, gosqlx_free's doc, and pygosqlx.version() all treat that string as caller-owned and free it. As a result, the second pygosqlx.version() call in a process double-frees. macOS aborts with pointer being freed was not allocated; on glibc it is heap corruption. I reproduced it on main: pytest python/tests aborts in TestVersion::test_version_format.

Fix (C side follows the documented contract):

  • gosqlx_version now returns a fresh C.CString(VERSION) on every call, and I removed the cache. The comments on gosqlx_version and gosqlx_free now state the ownership rule and that gosqlx_free(NULL) is a no-op.
  • Export audit: every other export returns a fresh allocation via toJSON on both success and error paths, including the marshal-failure fallback. None returns NULL or a static pointer. NULL input goes through C.GoString(NULL) and becomes "". gosqlx_version was the only mismatch in the library.
  • Second ownership bug, in the raw ctypes harness: pkg/cbinding/tests/test_ctypes.py declared results as c_char_p. That makes ctypes return a Python bytes copy, so gosqlx_free received a pointer it never allocated. It now uses c_void_p plus string_at, and it frees the version string too.

Tests

  • Go: getVersion() now frees its result; previously the stress test's "cached singleton, just read it" comment covered a call that never freed. The new TestCGosqlxVersion_CallerOwned calls gosqlx_version twice, asserts distinct pointers and equal strings equal to VERSION, and frees both. I confirmed it fails on the old code. Also added TestCGosqlxFree_Nil. I renamed TestStressVersionSingleton to TestStressVersionConcurrent.
  • Python: TestVersion::test_version_called_twice is the regression test. test_package_version.py asserts pygosqlx.__version__ and the setup.py version both equal the pyproject version.

CI: the new .github/workflows/python.yml runs on PRs and on pushes to main, filtered to pkg/**, python/**, go.mod, go.sum and the workflow file. It covers ubuntu-latest and macos-latest with Python 3.10 and 3.13, on Go 1.26. Steps: build with pkg/cbinding/build.sh, fail if the library is missing (so integration tests can't skip without anyone noticing), pip install -e "python[dev]", a two-call version() smoke test, pytest python/tests -v -rs, and the raw ctypes ABI tests. Actions are pinned to the same SHAs the repo already uses (checkout v4.4.0, setup-go v5.6.0, setup-python v5.6.0). Permissions are contents: read.

Design decisions I made (scoped worker, conservative choices):

  1. pygosqlx.__version__ was "0.1.0" while pyproject and setup.py declare 0.2.0. The required __version__ == pyproject test can only pass if they agree, so I changed __version__ to "0.2.0". This is byte-identical to the same hunk in release PR docs(release): prepare v1.16.0 #845, so the two merge cleanly in either order. The package version itself is unchanged.
  2. pip install -e "python[dev]" fails on current setuptools because setup.py passes long_description while pyproject's [project] table doesn't declare readme, which raises AttributeError in _long_description. I added readme = "README.md" to [project]. This changes metadata only, not the API or the version.
  3. I reused the repo's existing setup-python pin (v5.6.0) instead of introducing v7, for consistency.

Test Plan

  • Reproduced the abort on main with go build -buildmode=c-shared + GOSQLX_LIB_PATH=… pytest python/tests
  • gofmt -l pkg/cbinding (clean), go vet ./pkg/cbinding/, golangci-lint run ./pkg/cbinding/... (0 issues)
  • CGO_ENABLED=1 go test -race -count=1 ./pkg/cbinding/ passes; the new regression test fails against the old cbinding.go
  • Simulated the workflow locally (macOS, Python 3.15): build.sh, pip install -e "python[dev]", smoke test, pytest python/tests (79 passed), test_ctypes.py (25 OK); pip wheel ./python builds pygosqlx-0.2.0
  • Python Bindings workflow green on all 4 matrix legs

Breaking Changes

None for correct callers. A C caller that relied on the old (undocumented) behaviour and never freed the gosqlx_version() result now leaks a few bytes per call. The documented contract has always required gosqlx_free.

Release note

- **cbinding / pygosqlx:** fixed a crash where calling `pygosqlx.version()` more than once in a process aborted with "pointer being freed was not allocated" (double free). `gosqlx_version()` now returns a fresh, caller-owned string on every call, as documented; free it with `gosqlx_free`. `pygosqlx.__version__` now reports the packaged version (0.2.0), and editable installs work with current setuptools. A new Python Bindings CI workflow tests pygosqlx on Linux and macOS (Python 3.10 and 3.13).

🤖 Generated with Claude Code

https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9

…thon CI

gosqlx_version returned a sync.Once-cached C string while its doc comment,
gosqlx_free's doc, and pygosqlx.version() all treat it as caller-owned. The
second version() call in a process therefore freed the same pointer twice
and macOS aborted with "pointer being freed was not allocated".

- gosqlx_version now returns a fresh C.CString on every call, matching the
  documented contract of every other export; the cache is removed.
- Go tests: getVersion frees its result; new TestCGosqlxVersion_CallerOwned
  calls gosqlx_version twice and frees both; TestCGosqlxFree_Nil.
- pkg/cbinding/tests/test_ctypes.py declared results as c_char_p, so
  gosqlx_free received pointers to Python-owned bytes copies; use c_void_p
  and free the version string.
- python: regression test calling version() repeatedly, __version__ /
  setup.py vs pyproject consistency tests; align __version__ with the
  declared 0.2.0; declare readme in pyproject so editable installs work
  with current setuptools.
- .github/workflows/python.yml: build libgosqlx and run pytest plus the raw
  ctypes ABI tests on ubuntu/macos x Python 3.10/3.13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkzVu3JY9Y6BRWfjesWts9
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
gosqlx-website Ignored Ignored Preview Oct 21, 2026 6:15pm UTC

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 15s —— View job


Review of PR #847

Verdict: looks good. The fix is correct and minimal, and the tests target the real failure. I only read the diff and did not run the tests or CI. Nothing below blocks merging.

Fix (pkg/cbinding/cbinding.go)

  • gosqlx_version now returns a fresh C.CString(VERSION) on every call. That matches the documented ownership contract and removes the double free.
  • Dropping sync.Once and the sync import is clean. The cost is a few bytes allocated per call, which is acceptable.
  • The doc updates on gosqlx_free (NULL is a no-op) and gosqlx_version are accurate.

Tests

  • versionTwiceFreed skips the second free when the pointers match. That avoids crashing the test run on a regression, so the failure is reported as a clean assertion.
  • TestCGosqlxVersion_CallerOwned is a good regression test. Distinct pointers are a reasonable proxy for "fresh allocation", though the allocator could in theory reuse an address. That can't happen here, because the first pointer is still live when the second call runs.
  • test_ctypes.py: switching to c_void_p plus string_at is the correct way to keep the original pointer for gosqlx_free. Setting argtypes is a good hardening. In _call, the result_ptr is None check still works, because c_void_p returns None for NULL.
  • test_version_called_twice and test_package_version.py are fine. Minor nit: the setup.py regex version\s*=\s*"..." takes the first match in the file, so it could pick up an unrelated python_requires-style or other version= string if one is added later. It is fine for now.

CI (.github/workflows/python.yml)

  • The permissions, SHA pinning, concurrency group and fail-fast: false are all sensible.
  • The "Locate shared library" step fails loudly if the library is missing, so integration tests can't skip silently. That's a good call.
  • test -f and the uname case run under shell: bash, which is fine on both runners.
  • Suggestion: add cache: pip to setup-python, and cache-dependency-path: python/pyproject.toml, to speed the matrix up. This is optional.
  • The paths filter omits root-level files such as go.work, or a Taskfile that builds the library. Probably fine, but worth a thought.

Packaging

  • __version__ = "0.2.0" and readme = "README.md" are sensible. The __version__ hunk is identical to docs(release): prepare v1.16.0 #845, so the PRs merge cleanly in either order.
  • Confirm that python/README.md exists and is included in the sdist. A missing file would break pip install.

Remaining item: the checklist still shows the Python Bindings workflow as not yet green on all 4 matrix legs. Please confirm that before merging.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 15s —— View job


Review of PR #847

Verdict: looks good. I read the diff and checked a few files in the checkout. I did not run the tests or CI.

  • Read the diff and the surrounding files
  • Check packaging (python/README.md)
  • Post review

Fix (pkg/cbinding/cbinding.go)

  • gosqlx_version returns a fresh C.CString(VERSION) on every call. That matches the documented ownership contract and removes the double free.
  • Removing sync.Once and the sync import is clean. The cost is a few bytes per call.
  • The doc updates on gosqlx_free (NULL is a no-op) and gosqlx_version are accurate.

Tests

  • versionTwiceFreed skips the second free when the pointers are identical. A regression is then reported as a clean assertion instead of crashing the run.
  • getVersion() now frees its result, so the stress test follows the contract. TestCGosqlxVersion_CallerOwned is a good regression test, and you confirmed it fails on the old code.
  • test_ctypes.py: c_void_p plus string_at is the correct way to keep the original pointer for gosqlx_free.
  • Nit: test_package_version.py reads the setup.py version with a regex that takes the first version = "..." match. It would pick up the wrong string if another version= literal is added earlier in the file. It is fine today.

CI (.github/workflows/python.yml)

  • Permissions (contents: read), SHA pinning, the concurrency group and fail-fast: false are all sensible.
  • The "Locate shared library" step fails loudly if the library is missing, so integration tests can't skip silently.
  • Optional: add cache: pip and cache-dependency-path: python/pyproject.toml to setup-python to speed up the matrix.
  • Optional: the paths filter omits go.work. It probably isn't needed, but check.

Packaging

  • python/README.md exists, readme = "README.md" is now in pyproject.toml, and MANIFEST.in includes the README. The sdist concern is covered.
  • The __version__ = "0.2.0" hunk is byte-identical to the one in docs(release): prepare v1.16.0 #845, so the two PRs merge cleanly in either order.

Before merging: the test plan still shows the Python Bindings workflow as not green on all 4 matrix legs. Please confirm that.

@ajitpratap0
ajitpratap0 merged commit 073a085 into main Oct 21, 2026
34 checks passed
@ajitpratap0
ajitpratap0 deleted the fix/cbinding-version-double-free branch October 21, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant