pgjdbc/pgjdbc GitHub issues and pull requests (mirror)  
help / color / mirror / Atom feed
From: 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