pgjdbc/pgjdbc GitHub issues and pull requests (mirror)
help / color / mirror / Atom feedFrom: vlsi (@vlsi) <[email protected]>
To: pgjdbc/pgjdbc <[email protected]>
Subject: [pgjdbc/pgjdbc] PR #4188: feat: built-in Unix domain socket factory for Java 17+
Date: Mon, 15 Jun 2026 18:48:30 +0000
Message-ID: <[email protected]> (raw)
## Why
[Issue #3457](https://github.com/pgjdbc/pgjdbc/issues/3457) asks the driver to use `java.net.UnixDomainSocketAddress` (Java 16+) so local connections can go over a Unix domain socket without a third-party dependency such as junixsocket.
## What
- Add `org.postgresql.unixsocket.UnixDomainSocketFactory`, packaged in the multi-release JAR so it activates automatically on Java 17 and later.
- The base class (Java 8) is a stub that fails fast with a translatable `GT.tr` message.
- The working implementation lives under `META-INF/versions/17`. `UnixDomainSocket extends Socket` re-exposes a Unix NIO `SocketChannel` through the slice of the `Socket` API that `PGStream` uses: timed reads and writes via selectors (so `socketTimeout` and async-notify peeks keep working), plus the buffer and keep-alive accessors. TCP-only options such as `TCP_NODELAY` are accepted and ignored.
- Wire the `java17` source set into the Gradle build (`addMultiReleaseContents()` for both `jar` and `shadowJar`), and add a matching `jdkge17` profile to `reduced-pom.xml` so the Maven source-distribution build also produces `META-INF/versions/17`.
- docker-compose: a `DOMAIN_SOCKET` toggle bind-mounts the server socket directory to the host and widens permissions so host-side tests can connect.
- CI: a `domain_socket` matrix axis (Linux, Java 17+) routes every test connection through the factory via `-Ddomain_socket=<dir>` in `TestUtil`.
- Docs and changelog updated.
Usage:
```
jdbc:postgresql://localhost/test?socketFactory=org.postgresql.unixsocket.UnixDomainSocketFactory&socketFactoryArg=/var/run/postgresql
```
`socketFactoryArg` is the socket directory; the port from the URL selects `.s.PGSQL.<port>`. The host is ignored.
## How to verify
- `META-INF/versions/17` layout confirmed in the Gradle `jar`/`shadowJar` and in the Maven jar built from `:postgresql:sourceDistribution` (with `Multi-Release: true`).
- End-to-end against PostgreSQL 16 over a Unix socket on Java 21: `SELECT 1`, `version()`, `LISTEN`, and `socketTimeout=10` all succeed. (On macOS the socket must be reached from inside the Docker VM, since Docker Desktop bind mounts cannot carry an `AF_UNIX` connection to the host; this works directly on Linux CI.)
- `checkstyle`, `forbidden-apis`, and `autostyle` pass for the new sources.
- `UnixDomainSocketFactoryTest` covers the path in CI when the `domain_socket` axis is active and is skipped otherwise.
Closes #3457
🤖 Generated with [Claude Code](https://claude.com/claude-code)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index eb3692405d..939271cea5 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -164,15 +164,23 @@ jobs:
CREATE_REPLICAS: ${{ matrix.replication }}
PG_IMAGE: ${{ matrix.pg_version == 'HEAD' && 'pgjdbc/postgres-devel:ci-head' || '' }}
PG_PULL_POLICY: ${{ matrix.pg_version == 'HEAD' && 'build' || 'missing' }}
+ DOMAIN_SOCKET: ${{ matrix.domain_socket }}
# The below run command is long, however, it is intentional, and it makes the output nicer in GitHub UI
# language=bash
run: |
- echo "Starting PostgreSQL via docker compose down; PGV=$PGV TZ=$TZ XA=$XA SSL=$SSL SCRAM=$SCRAM CREATE_REPLICAS=$CREATE_REPLICAS docker compose up"
+ echo "Starting PostgreSQL via docker compose down; PGV=$PGV TZ=$TZ XA=$XA SSL=$SSL SCRAM=$SCRAM CREATE_REPLICAS=$CREATE_REPLICAS DOMAIN_SOCKET=$DOMAIN_SOCKET docker compose up"
- docker compose down -v --rmi local || true
+ # Expose the server's Unix domain socket to the host only for domain_socket runs, via a
+ # compose override that bind-mounts a host directory outside the repository.
+ COMPOSE_FILES="-f docker-compose.yml"
+ if [ "$DOMAIN_SOCKET" = "yes" ]; then
+ COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.domain-socket.yml"
+ mkdir -p /tmp/pgjdbc-postgresql-socket
+ fi
+ docker compose $COMPOSE_FILES down -v --rmi local || true
sed -i -r '/- (543[3-4]):\1/d' docker-compose.yml
- docker compose up -d --wait || { docker compose logs; exit 1; }
- docker compose logs
+ docker compose $COMPOSE_FILES up -d --wait || { docker compose $COMPOSE_FILES logs; exit 1; }
+ docker compose $COMPOSE_FILES logs
- name: Start PostgreSQL PGV=${{ matrix.pg_version }}
if: ${{ runner.os != 'Linux' }}
uses: ikalnytskyi/action-setup-postgres@c4dda34aae1c821e3a771b68b73b13af3198a7ee # v8
diff --git a/.github/workflows/matrix.mjs b/.github/workflows/matrix.mjs
index c851ccb1b2..c2976ab934 100644
--- a/.github/workflows/matrix.mjs
+++ b/.github/workflows/matrix.mjs
@@ -193,6 +193,17 @@ matrix.addAxis({
]
});
+// Connect over a Unix domain socket via org.postgresql.unixsocket.UnixDomainSocketFactory.
+// Requires Java 17+ (multi-release JAR) and a Unix host, so it is constrained below.
+matrix.addAxis({
+ name: 'domain_socket',
+ title: x => x.value === 'yes' ? 'domain_socket' : '',
+ values: [
+ {value: 'yes', weight: 3},
+ {value: 'no', weight: 10},
+ ]
+});
+
matrix.addAxis({
name: 'autosave',
title: x => x.value === 'never' ? '' : 'autosave ' + x.value,
@@ -269,7 +280,7 @@ function lessThan(minVersion) {
matrix.setNamePattern([
'java_version', 'java_distribution', 'pg_version', 'query_mode', 'scram', 'ssl', 'hash', 'os',
'server_tz', 'tz', 'locale',
- 'check_anorm_sbt', 'gss', 'replication', 'slow_tests',
+ 'check_anorm_sbt', 'gss', 'domain_socket', 'replication', 'slow_tests',
'adaptive_fetch', 'rewrite_batch_inserts', 'query_timeout',
'autosave', 'cleanupSavepoints', 'cpu_count'
]);
@@ -290,6 +301,17 @@ matrix.imply({java_distribution: {value: 'oracle'}}, {java_version: v => v === e
// TODO: Semeru does not ship Java 21 builds yet
matrix.exclude({java_distribution: {value: 'semeru'}, java_version: '21'})
matrix.imply({gss: {value: 'yes'}}, {os: {value: 'ubuntu-latest'}})
+// Unix domain sockets need Java 16+ (we ship the implementation in META-INF/versions/17) and a
+// Unix host where the server socket can be bind-mounted from the container.
+matrix.imply({domain_socket: {value: 'yes'}}, {os: {value: 'ubuntu-latest'}})
+matrix.imply({domain_socket: {value: 'yes'}}, {java_version: v => v === eaJava || v >= 17})
+// GSS encryption negotiates over TCP and is not used for local Unix socket connections
+matrix.exclude({domain_socket: {value: 'yes'}, gss: {value: 'yes'}})
+// unix_socket_directories (plural) was introduced in PostgreSQL 9.3; 9.1/9.2 use the singular GUC
+matrix.exclude({domain_socket: {value: 'yes'}, pg_version: lessThan('9.3')})
+// The replication host-chooser tests (MultiHostsConnectionTest) compare inet_server_addr() across
+// TCP hosts, which is null over a Unix socket, so keep those runs on TCP
+matrix.exclude({domain_socket: {value: 'yes'}, replication: {value: 'yes'}})
// ikalnytskyi/action-setup-postgres supports PostgreSQL 14+ only
matrix.exclude({os: {value: ['windows-latest', 'macos-latest']}, pg_version: lessThan('14')});
// HEAD is built from pgdg-snapshot inside Docker, which only runs on Linux.
@@ -319,6 +341,7 @@ matrix.generateRow({java_version: matrix.axisByName.java_version.values.slice(-2
// Ensure we test all query_mode values
matrix.ensureAllAxisValuesCovered('query_mode');
matrix.ensureAllAxisValuesCovered('gss');
+matrix.ensureAllAxisValuesCovered('domain_socket');
matrix.ensureAllAxisValuesCovered('xa');
matrix.ensureAllAxisValuesCovered('ssl');
matrix.ensureAllAxisValuesCovered('replication');
@@ -365,6 +388,7 @@ include.forEach(v => {
v.slow_tests = v.slow_tests.value;
v.xa = v.xa.value;
v.gss = v.gss.value;
+ v.domain_socket = v.domain_socket.value;
v.ssl = v.ssl.value;
v.scram = v.scram.value;
v.check_anorm_sbt = v.check_anorm_sbt.value;
@@ -451,6 +475,10 @@ include.forEach(v => {
if (v.gss === 'no') {
testJvmArgs.push('-DskipGssEncryption=true');
}
+ if (v.domain_socket === 'yes') {
+ // The docker-compose override bind-mounts the server socket directory to this host path
+ testJvmArgs.push('-Ddomain_socket=/tmp/pgjdbc-postgresql-socket');
+ }
if (v.cpu_count.value === '1') {
// Constrains ForkJoinPool common pool to a single worker, exposing FJP submit/compensation
// overhead in code paths like LazyCleaner. See https://github.com/pgjdbc/pgjdbc/issues/4037
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1429474664..58f095ffb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
### Security
### Added
+* feat: add a built-in Unix domain socket factory `org.postgresql.unixsocket.UnixDomainSocketFactory`, built on `java.net.UnixDomainSocketAddress` and shipped in the multi-release JAR so it activates automatically on Java 17 and later. Set `socketFactory=org.postgresql.unixsocket.UnixDomainSocketFactory` and `socketFactoryArg=<socket-directory>` (e.g. `/var/run/postgresql`) to connect over a Unix socket without an extra dependency such as junixsocket [Issue #3457](https://github.com/pgjdbc/pgjdbc/issues/3457)
* feat: invalidate the prepared-statement cache after CREATE/DROP/ALTER so callers no longer trip on "cached plan must not change result type" without opting into `autosave=ALWAYS`. Controlled by the new `flushCacheOnDdl` connection property (default `true`); set to `false` for the prior behaviour.
* feat: add `connectThreadFactory` connection property to customize the `ThreadFactory` used to spawn the worker thread that runs the connection attempt when `loginTimeout` is in effect. The value is the fully qualified name of a class implementing `java.util.concurrent.ThreadFactory`. With a null value, the default, the driver retains the prior behavior of using a daemon thread named `"PostgreSQL JDBC driver connection thread"`. Useful for testing timeout behaviour or for applications that want detailed control of all driver-created threads.
diff --git a/docker/postgres-server/docker-compose.domain-socket.yml b/docker/postgres-server/docker-compose.domain-socket.yml
new file mode 100644
index 0000000000..a311494fa4
--- /dev/null
+++ b/docker/postgres-server/docker-compose.domain-socket.yml
@@ -0,0 +1,10 @@
+# Compose override that exposes the server's Unix domain socket directory to the host, so tests can
+# connect via org.postgresql.unixsocket.UnixDomainSocketFactory. It is applied only for the
+# domain_socket CI runs (docker compose -f docker-compose.yml -f docker-compose.domain-socket.yml).
+# The host path lives outside the repository so that Docker creating it as root does not interfere
+# with the Gradle build directory. The entrypoint (DOMAIN_SOCKET=yes) widens the socket permissions
+# so the host user can reach it.
+services:
+ pgdb:
+ volumes:
+ - ${PG_SOCKET_DIR:-/tmp/pgjdbc-postgresql-socket}:/var/run/postgresql
diff --git a/docker/postgres-server/docker-compose.yml b/docker/postgres-server/docker-compose.yml
index e47b527fb1..9e10ed871e 100644
--- a/docker/postgres-server/docker-compose.yml
+++ b/docker/postgres-server/docker-compose.yml
@@ -31,6 +31,7 @@ services:
- AUTO_VACUUM=${AUTO_VACUUM:-no}
- TRACK_COUNTS=${TRACK_COUNTS:-no}
- CREATE_REPLICAS=${CREATE_REPLICAS:-no}
+ - DOMAIN_SOCKET=${DOMAIN_SOCKET:-no}
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=
- POSTGRES_DB=postgres
diff --git a/docker/postgres-server/scripts/entrypoint.sh b/docker/postgres-server/scripts/entrypoint.sh
index ccb5c469f5..f125eff388 100755
--- a/docker/postgres-server/scripts/entrypoint.sh
+++ b/docker/postgres-server/scripts/entrypoint.sh
@@ -58,6 +58,20 @@ main () {
pg_opts="${pg_opts} -c max_prepared_transactions=64"
fi
+ if is_option_enabled "${DOMAIN_SOCKET:-no}"; then
+ # The socket directory is bind-mounted from the host so that tests running on the host can
+ # connect via a Unix domain socket. Make it writable by postgres and world-accessible so the
+ # host user (a different uid) can connect to the socket.
+ mkdir -p /var/run/postgresql
+ chown postgres:postgres /var/run/postgresql
+ chmod 0777 /var/run/postgresql
+ add_pg_opt "-c unix_socket_directories=/var/run/postgresql"
+ add_pg_opt "-c unix_socket_permissions=0777"
+ # Allow local (Unix socket) connections for the unprivileged test user. The default
+ # pg_hba.conf only grants local access to the postgres superuser.
+ echo "local all all trust" >> "${pg_hba}"
+ fi
+
if is_pg_version_less_than "10"; then
add_pg_opt "-c max_locks_per_transaction=256"
fi
diff --git a/docs/content/documentation/use.md b/docs/content/documentation/use.md
index b87d221132..52f84f9857 100644
--- a/docs/content/documentation/use.md
+++ b/docs/content/documentation/use.md
@@ -482,6 +482,25 @@ A value of zero disables this check.
### Unix sockets
+On Java 17 and later the driver ships a built-in Unix domain socket factory, so no extra dependency
+is needed. It is built on `java.net.UnixDomainSocketAddress` and packaged in the multi-release JAR,
+so it activates automatically when the driver runs on a supported runtime.
+
+Add `?socketFactory=org.postgresql.unixsocket.UnixDomainSocketFactory&socketFactoryArg=[socket-directory]`
+to the connection URL, where `socketFactoryArg` is the directory that holds the server socket (for
+example `/var/run/postgresql`). The driver appends `.s.PGSQL.<port>` to that directory, so the port
+in the URL still selects the socket file. You may also point `socketFactoryArg` directly at a socket
+file. The host part of the URL is ignored, so `localhost` is a convenient placeholder:
+
+```
+jdbc:postgresql://localhost/test?socketFactory=org.postgresql.unixsocket.UnixDomainSocketFactory&socketFactoryArg=/var/run/postgresql
+```
+
+On Java 16 and earlier the built-in factory is unavailable and the constructor fails fast; use
+junixsocket instead (below).
+
+#### junixsocket
+
By adding junixsocket you can obtain a socket factory that works with the driver.
Code can be found [here](https://github.com/kohlschutter/junixsocket) and instructions
[here](https://kohlschutter.github.io/junixsocket/dependency.html)
diff --git a/pgjdbc/build.gradle.kts b/pgjdbc/build.gradle.kts
index 2f1a281281..a69d5d090e 100644
--- a/pgjdbc/build.gradle.kts
+++ b/pgjdbc/build.gradle.kts
@@ -54,6 +54,15 @@ val java11 by sourceSets.creating {
compileClasspath += sourceSets.main.get().output
}
+// Create a separate source set for Java 17+ specific code (e.g., Unix domain sockets)
+val java17 by sourceSets.creating {
+ java {
+ srcDir("src/main/java17")
+ }
+ // Make java17 source set depend on main source set (to access PGProperty and friends)
+ compileClasspath += sourceSets.main.get().output
+}
+
if (buildParameters.testJdkVersion >= 11) {
// By default, Gradle uses "test classes" dir for classpath, so multi-release jar is not used there
// So we explicitly prepend the classpath with Java 11 classes
@@ -62,6 +71,13 @@ if (buildParameters.testJdkVersion >= 11) {
}
}
+if (buildParameters.testJdkVersion >= 17) {
+ // Same as above: make the Java 17 multi-release classes visible to tests
+ tasks.test {
+ classpath = java17.output + classpath
+ }
+}
+
// Configure the java11 source set to compile with Java 11
tasks.named<JavaCompile>(java11.compileJavaTaskName) {
options.release.set(11)
@@ -69,10 +85,20 @@ tasks.named<JavaCompile>(java11.compileJavaTaskName) {
dependsOn(tasks.compileJava)
}
+// Configure the java17 source set to compile with Java 17
+tasks.named<JavaCompile>(java17.compileJavaTaskName) {
+ options.release.set(17)
+ // Ensure main classes are compiled before java17 classes
+ dependsOn(tasks.compileJava)
+}
+
fun CopySpec.addMultiReleaseContents() {
into("META-INF/versions/11") {
from(java11.output)
}
+ into("META-INF/versions/17") {
+ from(java17.output)
+ }
}
// Add java11 compiled classes to the main JAR
@@ -139,6 +165,7 @@ dependencies {
implementation("org.checkerframework:checker-qual:3.55.1")
java11.implementationConfigurationName("org.checkerframework:checker-qual:3.55.1")
+ java17.implementationConfigurationName("org.checkerframework:checker-qual:3.55.1")
testKitSourcesWithoutAnnotations(projects.testkit)
diff --git a/pgjdbc/reduced-pom.xml b/pgjdbc/reduced-pom.xml
index 4d15172bd3..f9a471201d 100644
--- a/pgjdbc/reduced-pom.xml
+++ b/pgjdbc/reduced-pom.xml
@@ -201,6 +201,43 @@
</plugins>
</build>
</profile>
+ <profile>
+ <id>jdkge17</id>
+ <activation>
+ <jdk>[17,)</jdk>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <executions>
+ <!--
+ On Java 17+ additionally compile the UnixDomainSocketAddress-based
+ implementation from src/main/java17 into META-INF/versions/17, so the
+ resulting jar supports Unix domain sockets. multiReleaseOutput places
+ the classes under META-INF/versions/17; the Multi-Release: true manifest
+ entry comes from src/main/resources/META-INF/MANIFEST.MF.
+ -->
+ <execution>
+ <id>compile-java17</id>
+ <phase>compile</phase>
+ <goals>
+ <goal>compile</goal>
+ </goals>
+ <configuration>
+ <release>17</release>
+ <compileSourceRoots>
+ <compileSourceRoot>${project.basedir}/src/main/java17</compileSourceRoot>
+ </compileSourceRoots>
+ <multiReleaseOutput>true</multiReleaseOutput>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
<!--
By default, source distribution does not build javadocs, however it can be activated with
-Pjavadoc
diff --git a/pgjdbc/src/main/java/org/postgresql/unixsocket/UnixDomainSocketFactory.java b/pgjdbc/src/main/java/org/postgresql/unixsocket/UnixDomainSocketFactory.java
new file mode 100644
index 0000000000..20d569a92e
--- /dev/null
+++ b/pgjdbc/src/main/java/org/postgresql/unixsocket/UnixDomainSocketFactory.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.unixsocket;
+
+import org.postgresql.util.GT;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.Properties;
+
+import javax.net.SocketFactory;
+
+/**
+ * A {@link SocketFactory} that connects to PostgreSQL over a Unix domain socket.
+ *
+ * <p>The implementation relies on {@link java.net.UnixDomainSocketAddress}, which is only available
+ * on Java 16 and later. The driver ships as a multi-release JAR: this base class is a stub that
+ * fails fast, and the real implementation lives in {@code META-INF/versions/17} and is selected
+ * automatically when the driver runs on Java 17 or later.</p>
+ *
+ * <p>To use it, set the {@code socketFactory} connection property to
+ * {@code org.postgresql.unixsocket.UnixDomainSocketFactory} and {@code socketFactoryArg} to the
+ * directory that holds the server socket (for example {@code /var/run/postgresql}). The driver
+ * appends {@code .s.PGSQL.<port>} to that directory, so the port from the JDBC URL still selects the
+ * socket file. The host part of the URL is ignored, so {@code localhost} is a fine placeholder.</p>
+ */
+public class UnixDomainSocketFactory extends SocketFactory {
+
+ public UnixDomainSocketFactory(Properties info) {
+ throw unsupported();
+ }
+
+ @Override
+ public Socket createSocket() throws IOException {
+ throw unsupported();
+ }
+
+ @Override
+ public Socket createSocket(String host, int port) throws IOException {
+ throw unsupported();
+ }
+
+ @Override
+ public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
+ throws IOException {
+ throw unsupported();
+ }
+
+ @Override
+ public Socket createSocket(InetAddress host, int port) throws IOException {
+ throw unsupported();
+ }
+
+ @Override
+ public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
+ throws IOException {
+ throw unsupported();
+ }
+
+ private static UnsupportedOperationException unsupported() {
+ return new UnsupportedOperationException(
+ GT.tr("Unix domain socket connections require Java 17 or later. "
+ + "Run the driver on Java 17 or newer to use "
+ + "org.postgresql.unixsocket.UnixDomainSocketFactory."));
+ }
+}
diff --git a/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocket.java b/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocket.java
new file mode 100644
index 0000000000..54640acbe3
--- /dev/null
+++ b/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocket.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.unixsocket;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import java.net.StandardProtocolFamily;
+import java.net.UnixDomainSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.ClosedSelectorException;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.SocketChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * A {@link Socket} adapter backed by a Unix domain {@link SocketChannel}.
+ *
+ * <p>{@code java.net.Socket} cannot speak the Unix protocol family directly, and
+ * {@code SocketChannel.socket()} throws for Unix channels, so this class re-exposes a Unix channel
+ * through the small slice of the {@code Socket} API that {@code PGStream} relies on:
+ * blocking reads and writes with an {@code SO_TIMEOUT}, plus the buffer/keep-alive getters and
+ * setters that the driver queries. TCP-only knobs such as {@code TCP_NODELAY} are accepted and
+ * ignored.</p>
+ */
+class UnixDomainSocket extends Socket {
+
+ private final String path;
+ private final SocketChannel channel;
+ private final Selector readSelector;
+ private final Selector writeSelector;
+
+ private volatile int soTimeout;
+ private int sendBufferSize = 8192;
+ private int receiveBufferSize = 8192;
+ private boolean tcpNoDelay = true;
+ private boolean keepAlive;
+ private volatile boolean closed;
+
+ private InputStream inputStream;
+ private OutputStream outputStream;
+
+ UnixDomainSocket(String path) throws IOException {
+ super((java.net.SocketImpl) null);
+ this.path = path;
+ this.channel = SocketChannel.open(StandardProtocolFamily.UNIX);
+ this.readSelector = Selector.open();
+ this.writeSelector = Selector.open();
+ }
+
+ /**
+ * Resolves the directory or socket file argument and the port from the JDBC URL into a concrete
+ * socket address. A bare directory is turned into {@code <dir>/.s.PGSQL.<port>}, matching the
+ * layout that PostgreSQL uses for its Unix sockets; an argument that already points at a socket
+ * file is used as is.
+ *
+ * @param path the {@code socketFactoryArg} value (a directory or a socket file)
+ * @param port the port from the connection URL
+ * @return the Unix domain socket address to connect to
+ */
+ static UnixDomainSocketAddress resolveAddress(String path, int port) {
+ Path candidate = Path.of(path);
+ Path fileName = candidate.getFileName();
+ boolean isSocketFile =
+ (fileName != null && fileName.toString().startsWith(".s.PGSQL."))
+ || (Files.exists(candidate) && !Files.isDirectory(candidate));
+ Path socket = isSocketFile ? candidate : candidate.resolve(".s.PGSQL." + port);
+ return UnixDomainSocketAddress.of(socket);
+ }
+
+ @Override
+ public void connect(SocketAddress endpoint) throws IOException {
+ connect(endpoint, 0);
+ }
+
+ // google-java-format (autostyle) cannot parse pattern-matching instanceof, so use the classic form
+ // and suppress the corresponding Error Prone suggestion.
+ @Override
+ @SuppressWarnings("PatternMatchingInstanceof")
+ public void connect(SocketAddress endpoint, int timeout) throws IOException {
+ UnixDomainSocketAddress address;
+ if (endpoint instanceof UnixDomainSocketAddress) {
+ address = (UnixDomainSocketAddress) endpoint;
+ } else if (endpoint instanceof InetSocketAddress) {
+ // PGStream builds an InetSocketAddress(host, port). The host is irrelevant for a Unix
+ // socket; we only use the port to locate the .s.PGSQL.<port> file.
+ address = resolveAddress(path, ((InetSocketAddress) endpoint).getPort());
+ } else {
+ throw new SocketException("Unsupported address type: " + endpoint);
+ }
+ // Connect in non-blocking mode so the connect honours the caller's timeout (PGStream passes
+ // connectTimeout here). A local connect is normally instant, but a stalled listener with a full
+ // accept backlog must not hang past the timeout, matching TCP behaviour. A timeout of 0 means
+ // no timeout, as for java.net.Socket.
+ channel.configureBlocking(false);
+ if (!channel.connect(address)) {
+ channel.register(writeSelector, SelectionKey.OP_CONNECT);
+ while (!channel.finishConnect()) {
+ int ready = writeSelector.select(timeout);
+ writeSelector.selectedKeys().clear();
+ if (ready == 0 && timeout > 0) {
+ throw new SocketTimeoutException("Connect timed out after " + timeout + " ms");
+ }
+ }
+ }
+ // Reads and writes honour SO_TIMEOUT through the selectors below.
+ channel.register(readSelector, SelectionKey.OP_READ);
+ channel.register(writeSelector, SelectionKey.OP_WRITE);
+ }
+
+ @Override
+ public boolean isConnected() {
+ return channel.isConnected();
+ }
+
+ @Override
+ public boolean isClosed() {
+ return closed;
+ }
+
+ @Override
+ public void bind(SocketAddress bindpoint) throws IOException {
+ // Binding the client side of a Unix domain socket is not meaningful here; ignore it so that
+ // localSocketAddress handling in PGStream is a no-op rather than a failure.
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ if (closed) {
+ throw new SocketException("Socket is closed");
+ }
+ if (inputStream == null) {
+ inputStream = new ChannelInputStream();
+ }
+ return inputStream;
+ }
+
+ @Override
+ public OutputStream getOutputStream() throws IOException {
+ if (closed) {
+ throw new SocketException("Socket is closed");
+ }
+ if (outputStream == null) {
+ outputStream = new ChannelOutputStream();
+ }
+ return outputStream;
+ }
+
+ // The buffer-size and timeout accessors are synchronized to match the contract of the
+ // java.net.Socket methods they override.
+
+ @Override
+ public synchronized void setSoTimeout(int timeout) throws SocketException {
+ if (timeout < 0) {
+ throw new IllegalArgumentException("timeout can't be negative");
+ }
+ this.soTimeout = timeout;
+ }
+
+ @Override
+ public synchronized int getSoTimeout() throws SocketException {
+ return soTimeout;
+ }
+
+ @Override
+ public synchronized void setSendBufferSize(int size) throws SocketException {
+ this.sendBufferSize = size;
+ }
+
+ @Override
+ public synchronized int getSendBufferSize() throws SocketException {
+ return sendBufferSize;
+ }
+
+ @Override
+ public synchronized void setReceiveBufferSize(int size) throws SocketException {
+ this.receiveBufferSize = size;
+ }
+
+ @Override
+ public synchronized int getReceiveBufferSize() throws SocketException {
+ return receiveBufferSize;
+ }
+
+ // TCP-only options below have no effect on a Unix domain socket, but the values are remembered so
+ // the get/set contract behaves like java.net.Socket (PGStream copies them between sockets).
+
+ @Override
+ public void setTcpNoDelay(boolean on) throws SocketException {
+ this.tcpNoDelay = on;
+ }
+
+ @Override
+ public boolean getTcpNoDelay() throws SocketException {
+ return tcpNoDelay;
+ }
+
+ @Override
+ public void setKeepAlive(boolean on) throws SocketException {
+ this.keepAlive = on;
+ }
+
+ @Override
+ public boolean getKeepAlive() throws SocketException {
+ return keepAlive;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ readSelector.close();
+ } catch (IOException ignore) {
+ // ignore
+ }
+ try {
+ writeSelector.close();
+ } catch (IOException ignore) {
+ // ignore
+ }
+ channel.close();
+ }
+
+ private int readFromChannel(ByteBuffer dst) throws IOException {
+ try {
+ int n = channel.read(dst);
+ if (n != 0) {
+ return n;
+ }
+ while (true) {
+ int ready = readSelector.select(soTimeout);
+ readSelector.selectedKeys().clear();
+ if (ready == 0) {
+ // Can only happen when soTimeout > 0 (a zero timeout blocks indefinitely).
+ throw new SocketTimeoutException("Read timed out after " + soTimeout + " ms");
+ }
+ n = channel.read(dst);
+ if (n != 0) {
+ return n;
+ }
+ }
+ } catch (ClosedSelectorException | ClosedChannelException e) {
+ throw closedDuringIo(e);
+ }
+ }
+
+ private void writeToChannel(ByteBuffer src) throws IOException {
+ try {
+ while (src.hasRemaining()) {
+ int n = channel.write(src);
+ if (n == 0) {
+ writeSelector.select();
+ writeSelector.selectedKeys().clear();
+ }
+ }
+ } catch (ClosedSelectorException | ClosedChannelException e) {
+ throw closedDuringIo(e);
+ }
+ }
+
+ /**
+ * Converts a selector or channel closed from another thread (for example {@code
+ * Connection.abort()} while a read is blocked) into a {@link SocketException}, so the driver maps
+ * it to an {@code SQLException} rather than letting an unchecked exception escape.
+ */
+ private static SocketException closedDuringIo(Exception cause) {
+ SocketException e = new SocketException("Socket closed");
+ e.initCause(cause);
+ return e;
+ }
+
+ // These streams are hand-written rather than obtained from java.nio.channels.Channels because the
+ // channel is non-blocking (so SO_TIMEOUT can be honoured through the selectors): Channels streams
+ // throw IllegalBlockingModeException on a non-blocking SelectableChannel, and a blocking channel
+ // would ignore SO_TIMEOUT, which PGStream relies on (socketTimeout and the setSoTimeout(1) peek in
+ // hasMessagePending).
+ private class ChannelInputStream extends InputStream {
+ private final byte[] single = new byte[1];
+
+ @Override
+ public int read() throws IOException {
+ int n = read(single, 0, 1);
+ return n == -1 ? -1 : single[0] & 0xff;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (len == 0) {
+ return 0;
+ }
+ if (closed) {
+ throw new SocketException("Socket is closed");
+ }
+ return readFromChannel(ByteBuffer.wrap(b, off, len));
+ }
+ }
+
+ private class ChannelOutputStream extends OutputStream {
+ @Override
+ public void write(int b) throws IOException {
+ write(new byte[]{(byte) b}, 0, 1);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ if (closed) {
+ throw new SocketException("Socket is closed");
+ }
+ writeToChannel(ByteBuffer.wrap(b, off, len));
+ }
+ }
+}
diff --git a/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocketFactory.java b/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocketFactory.java
new file mode 100644
index 0000000000..a295cce690
--- /dev/null
+++ b/pgjdbc/src/main/java17/org/postgresql/unixsocket/UnixDomainSocketFactory.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.unixsocket;
+
+import org.postgresql.PGProperty;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.Properties;
+
+import javax.net.SocketFactory;
+
+/**
+ * A {@link SocketFactory} that connects to PostgreSQL over a Unix domain socket using
+ * {@link java.net.UnixDomainSocketAddress} (Java 16+).
+ *
+ * <p>This is the Java 17 implementation that replaces the base stub when the driver runs on Java 17
+ * or later (it ships under {@code META-INF/versions/17} in the multi-release JAR).</p>
+ *
+ * <p>Set the {@code socketFactory} connection property to
+ * {@code org.postgresql.unixsocket.UnixDomainSocketFactory} and {@code socketFactoryArg} to the
+ * directory that holds the server socket (for example {@code /var/run/postgresql}). The port from
+ * the JDBC URL selects the socket file {@code .s.PGSQL.<port>} inside that directory. You may also
+ * point {@code socketFactoryArg} directly at a socket file. The host part of the URL is ignored.</p>
+ */
+public class UnixDomainSocketFactory extends SocketFactory {
+
+ private final String path;
+
+ public UnixDomainSocketFactory(Properties info) {
+ String arg = PGProperty.SOCKET_FACTORY_ARG.getOrDefault(info);
+ if (arg == null || arg.isEmpty()) {
+ arg = "/var/run/postgresql";
+ }
+ this.path = arg;
+ }
+
+ @Override
+ public Socket createSocket() throws IOException {
+ return new UnixDomainSocket(path);
+ }
+
+ @Override
+ public Socket createSocket(String host, int port) throws IOException {
+ Socket socket = createSocket();
+ socket.connect(UnixDomainSocket.resolveAddress(path, port));
+ return socket;
+ }
+
+ @Override
+ public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
+ throws IOException {
+ return createSocket(host, port);
+ }
+
+ @Override
+ public Socket createSocket(InetAddress host, int port) throws IOException {
+ return createSocket(host.getHostName(), port);
+ }
+
+ @Override
+ public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
+ throws IOException {
+ return createSocket(address.getHostName(), port);
+ }
+}
diff --git a/pgjdbc/src/test/java/org/postgresql/test/socketfactory/UnixDomainSocketFactoryTest.java b/pgjdbc/src/test/java/org/postgresql/test/socketfactory/UnixDomainSocketFactoryTest.java
new file mode 100644
index 0000000000..38da618e21
--- /dev/null
+++ b/pgjdbc/src/test/java/org/postgresql/test/socketfactory/UnixDomainSocketFactoryTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.test.socketfactory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import org.postgresql.PGProperty;
+import org.postgresql.test.TestUtil;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Properties;
+
+/**
+ * Verifies that connecting over a Unix domain socket via
+ * {@code org.postgresql.unixsocket.UnixDomainSocketFactory} works.
+ *
+ * <p>The test only runs when {@code -Ddomain_socket=<dir>} points at the directory that holds the
+ * server socket (the CI {@code domain_socket} matrix axis sets this), and the JVM is new enough for
+ * the multi-release implementation to be active.</p>
+ */
+class UnixDomainSocketFactoryTest {
+
+ @Test
+ void selectOverUnixDomainSocket() throws Exception {
+ String dir = System.getProperty("domain_socket");
+ assumeTrue(dir != null && !dir.isEmpty(),
+ "Set -Ddomain_socket=<dir> to enable Unix domain socket tests");
+ assumeTrue(hasUnixDomainSocketAddress(),
+ "Unix domain sockets require Java 16 or later");
+
+ Properties props = new Properties();
+ props.put(PGProperty.SOCKET_FACTORY.getName(),
+ "org.postgresql.unixsocket.UnixDomainSocketFactory");
+ props.put(PGProperty.SOCKET_FACTORY_ARG.getName(),
+ TestUtil.getFile(dir).getAbsolutePath());
+
+ try (Connection conn = TestUtil.openDB(props);
+ Statement st = conn.createStatement();
+ ResultSet rs = st.executeQuery("SELECT 1")) {
+ assertEquals(true, rs.next());
+ assertEquals(1, rs.getInt(1));
+ }
+ }
+
+ private static boolean hasUnixDomainSocketAddress() {
+ try {
+ Class.forName("java.net.UnixDomainSocketAddress");
+ return true;
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+}
diff --git a/testkit/src/main/java/org/postgresql/test/TestUtil.java b/testkit/src/main/java/org/postgresql/test/TestUtil.java
index 4e35b95187..d7845251f5 100644
--- a/testkit/src/main/java/org/postgresql/test/TestUtil.java
+++ b/testkit/src/main/java/org/postgresql/test/TestUtil.java
@@ -355,7 +355,24 @@ public static Connection openDB() throws SQLException {
*/
public static Connection openDB(Properties props) throws SQLException {
Properties propsWithDefaults = mergeDefaultProperties(props);
- return DriverManager.getConnection(getURL(propsWithDefaults), propsWithDefaults);
+ String url = getURL(propsWithDefaults);
+ // When tests run with -Ddomain_socket=<dir>, route the plain default connection through a Unix
+ // domain socket. Only connections that the caller did not customise are redirected: any test
+ // that passes its own properties (a different database or user, SSL, GSS, an auth plugin, ...)
+ // stays on TCP so it keeps exercising the host-based behaviour it targets. The socket factory is
+ // passed as a connection property rather than appended to the URL, so getURL() still matches
+ // PgConnection.getURL(). The directory is resolved like the other test resources, and the port
+ // from the URL selects the .s.PGSQL.<port> file.
+ String domainSocket = System.getProperty("domain_socket");
+ if (props.isEmpty() && domainSocket != null && !domainSocket.isEmpty()
+ && PGProperty.SOCKET_FACTORY.getOrDefault(propsWithDefaults) == null) {
+ // Copy first so System.getProperties() is not mutated for the no-properties case.
+ propsWithDefaults = new Properties(propsWithDefaults);
+ PGProperty.SOCKET_FACTORY.set(propsWithDefaults,
+ "org.postgresql.unixsocket.UnixDomainSocketFactory");
+ PGProperty.SOCKET_FACTORY_ARG.set(propsWithDefaults, getFile(domainSocket).getAbsolutePath());
+ }
+ return DriverManager.getConnection(url, propsWithDefaults);
}
/**
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: github://pgjdbc/pgjdbc
Cc: [email protected], [email protected]
Subject: Re: [pgjdbc/pgjdbc] PR #4188: feat: built-in Unix domain socket factory for Java 17+
In-Reply-To: <<[email protected]>>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox