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>
259 lines
7.6 KiB
Go
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)
|
|
}
|