Skip to content

Conversation

@xerial
Copy link
Member

@xerial xerial commented Jan 14, 2026

Implements a zero-dependency HTTP client using direct POSIX socket bindings,
eliminating the need for libcurl in Scala Native applications.

New files:

  • SocketBindings.scala: POSIX socket FFI bindings (socket, connect, send, recv)
  • Http11Protocol.scala: HTTP/1.1 request/response serialization and parsing
  • ScalaHttpChannel.scala: HttpChannel implementation using pure sockets
  • ScalaHttpClientBackend.scala: Backend factory for ScalaHttpChannel
  • SSLBindings.scala: Design document for future OpenSSL/TLS support

Features:

  • Full HTTP/1.1 protocol support
  • Content-Length and chunked transfer encoding
  • Configurable connect and read timeouts
  • Automatic redirect following (up to 10)
  • Connection keep-alive for reuse
  • DNS resolution via getaddrinfo

Limitations:

  • HTTP only (HTTPS requires OpenSSL bindings or libcurl)
  • Synchronous operations (async wraps sync in Rx)
  • No HTTP/2 support

This provides an alternative to NativeHttpClientBackend when libcurl
is not available or a minimal dependency footprint is required.

Implements a zero-dependency HTTP client using direct POSIX socket bindings,
eliminating the need for libcurl in Scala Native applications.

New files:
- SocketBindings.scala: POSIX socket FFI bindings (socket, connect, send, recv)
- Http11Protocol.scala: HTTP/1.1 request/response serialization and parsing
- ScalaHttpChannel.scala: HttpChannel implementation using pure sockets
- ScalaHttpClientBackend.scala: Backend factory for ScalaHttpChannel
- SSLBindings.scala: Design document for future OpenSSL/TLS support

Features:
- Full HTTP/1.1 protocol support
- Content-Length and chunked transfer encoding
- Configurable connect and read timeouts
- Automatic redirect following (up to 10)
- Connection keep-alive for reuse
- DNS resolution via getaddrinfo

Limitations:
- HTTP only (HTTPS requires OpenSSL bindings or libcurl)
- Synchronous operations (async wraps sync in Rx)
- No HTTP/2 support

This provides an alternative to NativeHttpClientBackend when libcurl
is not available or a minimal dependency footprint is required.
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @xerial, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request delivers a foundational pure Scala HTTP/1.1 client for Scala Native, designed for minimal dependencies and direct system interaction. By implementing core HTTP protocol logic and socket operations from scratch using POSIX bindings, it offers a lightweight and self-contained solution for making HTTP requests. The changes also include a forward-looking design for integrating HTTPS capabilities via OpenSSL, ensuring a clear path for future secure communication.

Highlights

  • Pure Scala HTTP/1.1 Client for Scala Native: Introduces a new HTTP/1.1 client implementation written entirely in Scala, specifically for Scala Native, eliminating external dependencies like libcurl for HTTP communication.
  • Direct POSIX Socket Bindings: The client leverages direct POSIX socket bindings for network operations, providing low-level control over TCP connections, DNS resolution, and data transfer.
  • Comprehensive HTTP/1.1 Feature Set: Supports full HTTP/1.1 protocol features including request/response serialization, Content-Length and chunked transfer encoding, connection keep-alive, automatic redirect following, and configurable timeouts.
  • HTTP-Only with Future HTTPS Design: The current implementation is HTTP-only. A dedicated design document (SSLBindings.scala) is included, outlining the future integration of OpenSSL bindings for HTTPS support.
  • Alternative to libcurl-based Backend: This new backend (ScalaHttpClientBackend) provides a zero-dependency alternative to the existing NativeHttpClientBackend (which relies on libcurl), ideal for environments where libcurl is unavailable or a minimal footprint is desired.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant and valuable feature: a pure-Scala, zero-dependency HTTP/1.1 client for Scala Native. The implementation is comprehensive, covering connection management, various body encoding strategies, and redirect handling. My review focuses on improving the robustness, correctness, and performance of this new client. I've identified several areas for improvement, including handling of chunked encoding trailers, parsing large chunk sizes, resolving relative redirect paths, and enhancing the low-level socket operations for IPv6 support and read efficiency. Addressing these points will make the new client more reliable and conformant to HTTP specifications.

}

val chunkSize = try {
java.lang.Long.parseLong(sizeStr, 16).toInt
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Casting the chunk size from Long to Int using .toInt can cause silent truncation if the chunk size exceeds Int.MaxValue. This could lead to incorrect parsing of the response body and potential data corruption. It's safer to validate that the parsed Long is within the Int range before casting.

For example:

val chunkSizeLong = java.lang.Long.parseLong(sizeStr, 16)
if (chunkSizeLong > Int.MaxValue) {
  throw new HttpClientException(
    Http.response(HttpStatus.BadGateway_502),
    HttpStatus.BadGateway_502,
    s"Chunk size too large: $chunkSizeLong"
  )
}
val chunkSize = chunkSizeLong.toInt

Comment on lines +277 to +280
if (chunkSize == 0) {
done = true
// Read trailing CRLF
receiveLineFunc()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for handling the end of a chunked response is incomplete. It reads only a single line after the zero-sized chunk. According to RFC 7230, there can be optional trailer headers before the final CRLF. This implementation will fail to parse responses with trailer headers correctly, which can corrupt the connection for subsequent requests in a keep-alive scenario.

You should loop and read lines until an empty line is encountered to correctly consume the entire trailer part.

Example fix:

if (chunkSize == 0) {
  done = true
  // Read and discard trailer headers until an empty line is found
  var trailerDone = false
  while (!trailerDone) {
    receiveLineFunc() match {
      case SocketBindings.SocketSuccess(line) =>
        if (line.trim.isEmpty) {
          trailerDone = true
        }
      case SocketBindings.SocketError(code, msg) =>
        throw new HttpClientException(
          Http.response(HttpStatus.BadGateway_502),
          HttpStatus.BadGateway_502,
          s"Failed to read chunk trailer: $msg"
        )
    }
  }
} else {
  // ...
}

Comment on lines +394 to +397
val basePath = original.uri.lastIndexOf('/') match {
case -1 => "/"
case idx => original.uri.substring(0, idx + 1)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for resolving relative redirect paths is incorrect when the original URI does not contain a slash. For an original URI like foo and a relative location bar, this code produces /bar instead of bar. The base path should be empty in this case, not /.

This implementation also doesn't handle relative path components like . or .., which can lead to incorrect redirect URLs. A more concise and correct way to compute the base path for simple relative redirects is shown in the suggestion.

      val basePath = original.uri.substring(0, original.uri.lastIndexOf('/') + 1)

Zone.acquire { implicit z =>
val hints = stackalloc[addrinfo]()
string.memset(hints.asInstanceOf[Ptr[Byte]], 0, sizeof[addrinfo])
hints.ai_family = AF_INET
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Hardcoding ai_family to AF_INET restricts connections to IPv4 only. To support both IPv4 and IPv6, you should use AF_UNSPEC. This will allow getaddrinfo to return both types of addresses if available.

For improved robustness, you should also consider iterating through the linked list of addrinfo structures returned by getaddrinfo and attempting to connect to each one until a connection is successful, as the first address may not be reachable.

Suggested change
hints.ai_family = AF_INET
hints.ai_family = AF_UNSPEC

Comment on lines +267 to +298
def receiveUntil(handle: SocketHandle, delimiter: Array[Byte], maxBytes: Int): SocketResult[Array[Byte]] = {
val builder = new scala.collection.mutable.ArrayBuffer[Byte](1024)
val singleByte = new Array[Byte](1)

while (builder.length < maxBytes) {
receive(handle, 1) match {
case SocketSuccess(data) =>
if (data.isEmpty) {
return SocketError(ECONNRESET, "Connection closed before delimiter found")
}
builder += data(0)

// Check if we've received the delimiter
if (builder.length >= delimiter.length) {
var matches = true
var i = 0
while (i < delimiter.length && matches) {
if (builder(builder.length - delimiter.length + i) != delimiter(i)) {
matches = false
}
i += 1
}
if (matches) {
return SocketSuccess(builder.toArray)
}
}
case err: SocketError =>
return err
}
}
SocketError(-1, s"Delimiter not found within $maxBytes bytes")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The implementation of receiveUntil reads from the socket one byte at a time (receive(handle, 1)). This is extremely inefficient as it results in a separate system call for each byte received, leading to poor performance.

A much more efficient approach would be to read a larger chunk of data into a buffer (e.g., 8192 bytes) and then search for the delimiter within that buffer, accumulating data across reads if necessary. You can see a better pattern for this in ScalaHttpChannel.readResponseHeaders.

Comment on lines +48 to +76
// Socket types
final val SOCK_STREAM: CInt = 1

// Protocol families
final val AF_INET: CInt = 2
final val AF_INET6: CInt = 10

// AI flags for getaddrinfo
final val AI_PASSIVE: CInt = 0x0001
final val AI_CANONNAME: CInt = 0x0002
final val AI_NUMERICHOST: CInt = 0x0004

// Socket options
final val SOL_SOCKET: CInt = 1
final val SO_RCVTIMEO: CInt = 20
final val SO_SNDTIMEO: CInt = 21
final val SO_KEEPALIVE: CInt = 9
final val SO_ERROR: CInt = 4

// Error codes
final val EINPROGRESS: CInt = 115
final val EWOULDBLOCK: CInt = 11
final val ETIMEDOUT: CInt = 110
final val ECONNREFUSED: CInt = 111
final val ENETUNREACH: CInt = 101
final val EHOSTUNREACH: CInt = 113
final val ECONNRESET: CInt = 104
final val ENOTCONN: CInt = 107
final val EPIPE: CInt = 32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Many of the constants defined here (e.g., SOCK_STREAM, AF_INET, SO_RCVTIMEO, ETIMEDOUT) are already provided by Scala Native's POSIX bindings, which are imported in this file (e.g., scala.scalanative.posix.sys.socket._). Redefining them locally is redundant and can lead to maintenance issues or inconsistencies with the system headers. It would be better to remove these local definitions and use the imported constants directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants