Skip to content

feat(provision): accept a unix socket host - #5

Merged
mrcsin merged 4 commits into
masterfrom
socket-host
Aug 24, 2026
Merged

mrcsin merged 4 commits into
masterfrom
socket-host

Conversation

@mrcsin

@mrcsin mrcsin commented Aug 21, 2026

Copy link
Copy Markdown
Member

semibase could not connect over a unix socket, which blocks its intended container use.

Options.connect built a connection string as a url.URL whose authority came from net.JoinHostPort. A socket directory is a filesystem path, and its slashes are not valid in a URL authority — url.Parse rejects the result with invalid URL escape "%2F" before any network call happens.

That matters because of how the official postgres image works. Its entrypoint starts a temporary server with listen_addresses empty — no TCP at all — and runs everything in /docker-entrypoint-initdb.d/ against it over the socket. So semibase create could not run as an init script, which is exactly what SemiPlot's test bench needs in order to have one container provision itself before its port opens.

What changed

A socket host now goes into the URL's host query parameter instead of the authority: postgres://user:pass@/db?host=%2Fvar%2Frun%2Fpostgresql&port=5432. The TCP string is byte-identical to what it was — hostnames, IP literals, IPv6 in brackets, non-default ports.

Socket detection matches pgconn.isAbsolutePath clause for clause, including its quirk of requiring an upper-case Windows drive letter, so this tool and the driver never disagree about what a socket host is. The @ abstract-namespace form is deliberately excluded: measured against the pinned pgx, pgconn.NetworkAddress("@abstract", 5432) returns tcp, so pgx has no abstract-socket support and routing @ into the host parameter would advertise something that does not exist.

The error and "Done" messages name the socket file rather than a host:port that was never dialled.

Verification

The mechanism was proven end to end before this PR was opened, not argued:

  • A container built from postgres:17-alpine with this binary in /docker-entrypoint-initdb.d/ provisions over the socket. docker logs shows the temp server listening on the Unix socket with no TCP line, then create completing.
  • Provisioned before the port opens: a poller started ahead of the database, connecting as semiplot_reader every 100 ms, never once saw role does not exist or database does not exist. The first connection that authenticated already saw the complete result.
  • Negative control: the same image built from master's binary fails with invalid URL escape "%2F", the container exits 1, and the port never opens.
  • Fail-closed proven, not assumed: re-running with a deliberately wrong --expected-major exits 1 and the port stays shut.

CI gains a step that runs that same recipe, so the motivating path is covered rather than only its string construction. The step was executed locally against Docker 29.7.2 before being written into the workflow; no GitHub Actions run has been observed yet.

TestConnectionStringIsDialedAsTheRightNetwork asserts pgconn.NetworkAddress yields unix for the socket form and tcp for TCP — the property that matters, rather than a golden string copy-pasted from the implementation.

Notes for the consumer

Facts measured while proving this, which SemiPlot's Dockerfile will need:

  • socket directory /var/run/postgresql; init scripts run as postgres, uid 70, cwd /
  • superuser auth during init is local trust; no super password needed
  • init scripts run only on an empty PGDATA, so a reused volume skips them entirely
  • docker-proxy binds the published host port immediately, so a raw TCP-connect readiness probe is a false positive — probe with pg_isready or a real query
  • the entrypoint sources a non-executable .sh rather than running it, so the init script must be executable or set -e and exec inside it would hijack the entrypoint process

🤖 Generated with Claude Code

mrcsin and others added 4 commits August 17, 2026 12:25
A socket directory cannot ride in the URL authority: its slashes end the
authority. libpq and pgx read the socket path from the host query
parameter instead, so connect now branches on a leading slash and builds
postgres:///db?host=/var/run/postgresql for that case. The TCP form is
untouched.

This is what the official postgres image needs. Its entrypoint runs
/docker-entrypoint-initdb.d/ against a temporary server started with
listen_addresses empty, reachable over the socket only, so semibase
create could not run as an init script before.

Extracted connectionString from connect so the construction can be
tested without a server, and Endpoint so the two places that formatted
host:port for a message (the connect error, the CLI's Done line) name
the socket file instead when the host is a socket directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The socket endpoint printed <dir>/.s.PGSQL.<port>/<database>, which makes the
socket file read as a directory and sends the operator to a path that can
never exist. The database now rides in parentheses, so the socket path stays
quotable. The TCP form is unchanged.

The socket test claimed a leading slash was libpq's own. It is not: libpq's
is_unixsock_path also takes "@" for Linux's abstract namespace, and the driver
in play is pgx, whose isAbsolutePath takes a leading slash or a drive-letter
path. Detection now matches pgx clause for clause. "@" stays out on purpose:
pgx resolves such a host as a TCP name, so accepting it would only move the
failure one layer later.

TestConnectionStringIsParsedByTheDriver could not tell an encoded slash from
an unencoded one - both spellings parse to the same config.Host. It is now
TestConnectionStringIsDialedAsTheRightNetwork and asserts the network
pgconn.NetworkAddress derives, unix for the socket form and tcp for the TCP
form; the address itself is filepath.Join'd, so it differs by GOOS and is not
asserted. TestIsSocketHost pins the boundary directly.

Endpoint was exported only for the CLI's Done line and was handed the
receiver's own field. It is now Endpoint() on the receiver over an unexported
endpoint(database) for the connect error, and the Done line has a run-level
test.

CI's Linux job gains the step that covers the motivating path: it layers the
freshly built binary onto postgres:17-alpine behind an init script and asserts
semiplot_reader can select over TCP, which only opens after the init scripts
have run. Nothing is published - docker-proxy binds a published host port
before postgres listens.

Docs: the lead no longer says pgx talks over TCP only, the --host rules move
out of "Development benches" into a connection section of their own, the
example URL is the percent-encoded one the tool actually emits, and the
Russian operator table names the socket form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The socket step built without CGO_ENABLED=0. The runner has a C toolchain,
so the default is 1 and the binary links against glibc, while
postgres:17-alpine is musl. The image reports it as "not found", which
reads as a missing file rather than a missing interpreter and sends a
reader looking for the wrong thing.

release.yml already builds the shipped Linux artifact with CGO_ENABLED=0,
so only this step was wrong.

A file(1) assertion follows the build, because a linking mistake should
fail where it happens and say what it is, not several steps later as a
shell reporting that an executable it just chmod'ed does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mrcsin
mrcsin merged commit 254451f into master Aug 24, 2026
2 checks passed
@mrcsin
mrcsin deleted the socket-host branch August 24, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant