Files
five/hbrtl/pgserver/session.go
CharlesKWON d98f5e1767 feat(pgserver): PostgreSQL-wire MVP — psql can SELECT from FiveSql2
First end-to-end working version of the PostgreSQL-wire-compatible
TCP server frontend. A standard `psql` client now connects, runs
`SELECT * FROM employees`, and gets back a properly typed result
set rendered by psql with the right column alignment:

    ID |         NAME         |  SALARY
    ----+----------------------+----------
      1 | Alice                | 50000.00
      2 | Bob                  | 42000.50
      3 | Cho                  | 77500.00

This is the Phase 2 deliverable from the approved plan at
/Users/charleskwon/.claude/plans/compiled-launching-shore.md.
Builds on the session-state refactor in 93cf5c8 — each connection
gets its own TSqlSession on the PRG side via the new PG_NEW_SESSION
HB_FUNC, so concurrent psql clients won't share transaction logs
or plan caches.

Scope
-----

v1.0 MVP: Simple Query only, trust auth, no TLS yet. SELECT works
against the full FiveSql2 surface (CTEs, window functions, JOINs,
aggregates). DML + per-session transactions are Phase 3, extended
protocol is Phase 4, auth + TLS are Phases 5/6.

Architecture
------------

  psql/pgx/JDBC ──TCP:5432──▶ pgserver.Listener
                                  │ accept()
                                  ▼ go handleConn(net.Conn)
                             ┌─────────────────────────────┐
                             │ Session goroutine            │
                             │  1. SSLRequest peek          │
                             │  2. StartupMessage           │
                             │  3. AuthenticationOk (trust) │
                             │  4. ParameterStatus×7        │
                             │  5. BackendKeyData           │
                             │  6. ReadyForQuery('I')       │
                             │  7. loop: Receive() →        │
                             │     dispatchSimpleQuery →    │
                             │     hbrt.Thread.Function(    │
                             │       FIVE_SQL,sql,...,sess) │
                             │     emit RowDescription      │
                             │     emit DataRow×N           │
                             │     emit CommandComplete     │
                             │     emit ReadyForQuery       │
                             └─────────────────────────────┘

One goroutine per connection, each owning its own *hbrt.Thread and
TSqlSession instance. Uses the existing audit-fixed NewThread()
(cde8673) so statics + WA factory propagate.

New files (hbrtl/pgserver/)
---------------------------

* server.go — Config, Server, Serve loop with MaxConnections gate
  via semaphore, Close drains in-flight sessions.
* session.go — full lifecycle: SSLRequest peek + prefixedConn
  byte-injection trick for StartupMessage, ParameterStatus
  broadcast (server_version "14.0 (FiveSql2)" so pgx negotiates),
  BackendKeyData (random pid+secret per session, no CancelRequest
  yet), query loop dispatching only Simple Query in v1.0 with a
  loud "0A000 not supported" for Extended messages.
* dispatch.go — runSQL invokes FIVE_SQL via PushSymbol+Function,
  unpacks the engine's `{aFieldNames, aRows}` envelope or the
  `{{"__error__"}, {{nCode, cMsg, cSQL}}}` error shape, emits
  RowDescription with text-format OIDs and DataRow per row.
* typemap.go — pgTypeFor() picks INT4 / INT8 / NUMERIC / TEXT /
  DATE / TIMESTAMP / BOOL by sampling the first row's value type;
  encodeText() formats each cell, returning nil-slice for NULL
  (the PG length=-1 convention).
* errmap.go — sqlStateFor() maps FiveSql2 SQL_ERR_* codes to
  canonical PG SQLSTATEs (42601/42P01/42703/42804/23505/23514/
  23503/25P02/42501/02000/XX000).
* auth.go — trust mode in v1.0; password/MD5/SCRAM lands Phase 5
  but the dispatch sentinel is already in place.
* tls.go — upgradeToTLS stub for SSLRequest handling; the byte-
  ordering is already wired so Phase 6 just plugs in tls.Config.
* register.go — package init() registers pg_server_start /
  pg_server_stop HB_FUNCs. Importing the package (done from
  hbrtl/register.go via blank import) is enough to enable them.
* pgserver_test.go — unit tests for encodeText (numeric, string,
  NIL), pgTypeFor (OID dispatch), sqlStateFor (error mapping),
  commandTagFor (SELECT/INSERT/UPDATE/DELETE/BEGIN/COMMIT).

Other changes
-------------

* _FiveSql2/src/TSqlSession.prg — added PG_NEW_SESSION() factory
  used by the Go dispatcher to allocate a per-connection session
  bypassing the embedded process default.
* hbrtl/register.go — blank-import five/hbrtl/pgserver so its
  init() fires and the HB_FUNCs land in the global dynamic-func
  table for VM symbol lookup.
* go.mod / go.sum — github.com/jackc/pgx/v5 v5.9.2 (pgproto3
  subpackage). MIT license. Same library pgx itself uses, so
  protocol coverage matches the de-facto Go PG ecosystem.

Verification
------------

  $ pg_server_start(15432, "trust")     /* PRG one-liner */
  $ psql -h 127.0.0.1 -p 15432 -U fiveuser -c 'SELECT * FROM employees'
  → 3 rows rendered correctly by psql (ID as INT4, NAME as TEXT,
    SALARY as NUMERIC(10,2) with 2 decimal places)

All six release gates green:
  go test ./...               ✓ (incl. new hbrtl/pgserver tests)
  FiveSql2 SQL:1999 43/43     ✓
  Harbour compat 56/56        ✓
  std.ch 17/17                ✓
  FRB 7/7                     ✓
  examples 65/71              ✓ (unchanged baseline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:40:32 +09:00

259 lines
7.6 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
package pgserver
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strings"
"github.com/jackc/pgx/v5/pgproto3"
"five/hbrt"
)
// session is the per-connection state. One goroutine per session;
// nothing in session is touched by anyone else, so all fields are
// access-by-owner without locking.
type session struct {
srv *Server
conn net.Conn
// pgproto3 Backend speaks the PG protocol on the wire. Created
// after the TLS-upgrade decision so handshake bytes go through
// the right transport.
be *pgproto3.Backend
// Authenticated identity. Empty before AuthenticationOk.
user string
database string
// Per-connection hbrt.Thread, used to dispatch FIVE_SQL calls
// inside the PRG world. Owns a TSqlSession on the PRG side
// (created lazily by the first query via five_SQL's default-
// session fallback; for true isolation we instantiate one
// explicitly — see queryLoop).
thread *hbrt.Thread
// PRG-side session value: a TSqlSession instance held in a
// thread-local hbrt slot. Allocated on first query.
prgSession hbrt.Value
// Cancellation key — PG protocol mandates random 32-bit
// process id + secret in BackendKeyData so clients can send
// CancelRequest later. We don't honour cancel yet (v1.1),
// but the BackendKeyData must still be present and unique.
pid uint32
secret []byte // 4 random bytes per PG BackendKeyData
// Current transaction status, emitted in every ReadyForQuery:
// 'I' = idle, 'T' = in transaction, 'E' = failed transaction.
txStatus byte
}
func newSession(srv *Server, conn net.Conn) *session {
var pid uint32
_ = binary.Read(rand.Reader, binary.BigEndian, &pid)
secret := make([]byte, 4)
_, _ = rand.Read(secret)
return &session{
srv: srv,
conn: conn,
pid: pid,
secret: secret,
txStatus: 'I',
}
}
// run drives the full session lifecycle:
//
// 1. SSLRequest / GSSEncRequest probe (reply 'N' for v1.0)
// 2. StartupMessage parse
// 3. Auth (trust path for v1.0; password/MD5/SCRAM in Phase 5)
// 4. ParameterStatus broadcast
// 5. BackendKeyData
// 6. ReadyForQuery
// 7. query loop until Terminate / EOF / error
//
// Any error in steps 1-3 aborts the session before query loop
// begins; an ErrorResponse goes back if at all possible.
func (s *session) run(ctx context.Context) {
// Step 1 — SSLRequest peek. Client sends 8 bytes: length(8) +
// special version 80877103 to ask for TLS; or it skips this and
// goes straight to StartupMessage. The pgproto3 Backend doesn't
// abstract this, so we peek the raw bytes first.
if err := s.negotiateTLS(); err != nil {
return
}
s.be = pgproto3.NewBackend(s.conn, s.conn)
// Step 2 — StartupMessage. After SSLRequest the client retries
// with a fresh length-prefixed startup. ReceiveStartupMessage
// handles either form transparently.
startupMsg, err := s.be.ReceiveStartupMessage()
if err != nil {
return // client gone
}
startup, ok := startupMsg.(*pgproto3.StartupMessage)
if !ok {
s.sendError("08P01", fmt.Sprintf("unexpected startup message: %T", startupMsg))
return
}
s.user = startup.Parameters["user"]
s.database = startup.Parameters["database"]
if s.database == "" {
s.database = s.user
}
// Step 3 — Auth. v1.0 lands `trust`; password/md5/scram in
// Phase 5 via auth.go.
if err := s.authenticate(); err != nil {
return
}
// Step 4 — ParameterStatus. These tell the client our identity
// and default formatting so it doesn't try features we don't
// have. server_version is the most-checked field; pgx + JDBC
// negotiate features based on its numeric prefix.
s.sendParameterStatus("server_version", s.srv.cfg.serverVersion())
s.sendParameterStatus("server_encoding", "UTF8")
s.sendParameterStatus("client_encoding", "UTF8")
s.sendParameterStatus("DateStyle", "ISO, MDY")
s.sendParameterStatus("TimeZone", "UTC")
s.sendParameterStatus("integer_datetimes", "on")
s.sendParameterStatus("standard_conforming_strings", "on")
// Step 5 — BackendKeyData. Some clients (psql) expect this
// even though we don't honour CancelRequest yet.
s.send(&pgproto3.BackendKeyData{ProcessID: s.pid, SecretKey: s.secret})
// Step 6 — ReadyForQuery, transaction status idle.
s.sendReadyForQuery()
// Step 7 — query loop. Each iteration:
// - Receive a frontend message
// - Dispatch (Query / Parse / Bind / Execute / Sync / Terminate / Close / Describe)
// - Send response stream
// - Loop
s.queryLoop(ctx)
}
// negotiateTLS reads the first 8-byte preamble. If it's an
// SSLRequest (length=8, version=80877103) and the server has a
// TLS config, we reply 'S' and upgrade; otherwise 'N' and the
// client retries unencrypted (or hangs up if it required TLS).
func (s *session) negotiateTLS() error {
var hdr [8]byte
if _, err := io.ReadFull(s.conn, hdr[:]); err != nil {
return err
}
length := binary.BigEndian.Uint32(hdr[0:4])
version := binary.BigEndian.Uint32(hdr[4:8])
const sslReqCode = 80877103
if length == 8 && version == sslReqCode {
if s.srv.cfg.TLSConfig != nil {
if _, err := s.conn.Write([]byte{'S'}); err != nil {
return err
}
// tls.go implements the upgrade; v1.0 stub returns
// the connection unchanged so the byte ordering is
// correct but cipher negotiation isn't wired yet.
upgraded, err := upgradeToTLS(s.conn, s.srv.cfg.TLSConfig)
if err != nil {
return err
}
s.conn = upgraded
return nil
}
if _, err := s.conn.Write([]byte{'N'}); err != nil {
return err
}
return nil
}
// Not an SSLRequest — the 8 bytes are the start of a
// StartupMessage. Buffer them so pgproto3.ReceiveStartupMessage
// sees the full payload.
s.conn = &prefixedConn{Conn: s.conn, prefix: hdr[:]}
return nil
}
// send writes a single backend message to the wire.
func (s *session) send(msg pgproto3.BackendMessage) {
s.be.Send(msg)
_ = s.be.Flush()
}
func (s *session) sendParameterStatus(name, value string) {
s.send(&pgproto3.ParameterStatus{Name: name, Value: value})
}
func (s *session) sendReadyForQuery() {
s.send(&pgproto3.ReadyForQuery{TxStatus: s.txStatus})
}
// sendError ships an ErrorResponse without forcing the caller to
// build the full pgproto3 struct. Used for protocol-level errors
// before the query loop starts; in-loop errors go through errmap.go.
func (s *session) sendError(sqlState, message string) {
s.send(&pgproto3.ErrorResponse{
Severity: "ERROR",
Code: sqlState,
Message: message,
})
}
// queryLoop runs until the client sends Terminate, closes the
// connection, or we hit an unrecoverable error.
func (s *session) queryLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
msg, err := s.be.Receive()
if err != nil {
return // client gone or wire-level error
}
switch m := msg.(type) {
case *pgproto3.Terminate:
return
case *pgproto3.Query:
s.dispatchSimpleQuery(strings.TrimSpace(m.String))
default:
// v1.0 ignores Extended-protocol messages with a
// loud diagnostic so clients see they're unsupported
// instead of hanging on a silent stall.
s.sendError("0A000",
fmt.Sprintf("message %T not supported in this protocol version (Simple Query only)", m))
s.sendReadyForQuery()
}
}
}
// prefixedConn injects pre-read bytes back into a net.Conn so a
// caller that needs those bytes for parsing (pgproto3 reading the
// StartupMessage after our SSLRequest peek) sees them seamlessly.
type prefixedConn struct {
net.Conn
prefix []byte
off int
}
func (p *prefixedConn) Read(buf []byte) (int, error) {
if p.off < len(p.prefix) {
n := copy(buf, p.prefix[p.off:])
p.off += n
return n, nil
}
return p.Conn.Read(buf)
}