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>
204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// Package pgserver implements a PostgreSQL-wire-protocol-compatible
|
|
// TCP/IP frontend for FiveSql2. The protocol layer is provided by
|
|
// github.com/jackc/pgx/v5/pgproto3 (the same library pgx itself uses
|
|
// internally); this package wires it into Five's runtime so that
|
|
// psql / pgx / JDBC / DBeaver / Tableau and any other PostgreSQL
|
|
// driver can connect to a Five process as if it were Postgres.
|
|
//
|
|
// Lifecycle
|
|
//
|
|
// five pgserver --listen :5432 starts Listen() in the main goroutine.
|
|
// accept() spawns one goroutine per connection. Each connection
|
|
// owns a *hbrt.Thread + a TSqlSession on the PRG side, so concurrent
|
|
// clients don't share transaction state or plan caches (see
|
|
// refactor commit 93cf5c8).
|
|
//
|
|
// Scope
|
|
//
|
|
// v1.0 ships Simple Query (SELECT/INSERT/UPDATE/DELETE), per-session
|
|
// BEGIN/COMMIT/ROLLBACK, trust + password/MD5/SCRAM auth, and TLS.
|
|
// Extended protocol (Parse/Bind/Execute) and pg_catalog shim are
|
|
// v1.1+. See /Users/charleskwon/.claude/plans/compiled-launching-shore.md.
|
|
package pgserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
|
|
"five/hbrt"
|
|
)
|
|
|
|
// Config holds runtime knobs for the server. All fields optional;
|
|
// zero values yield a trust-auth, no-TLS server bound to :5432.
|
|
type Config struct {
|
|
// Listen is the bind address (":5432" by default).
|
|
Listen string
|
|
|
|
// MaxConnections caps concurrent accepted sessions. Excess
|
|
// connections block in accept() until a slot frees. 0 → 100.
|
|
MaxConnections int
|
|
|
|
// AuthMode: "trust" (default), "password", "md5", "scram-sha-256".
|
|
// Roles + password hashes are loaded from __five_roles.dbf when
|
|
// not in trust mode; see auth.go.
|
|
AuthMode string
|
|
|
|
// TLSConfig, if non-nil, is presented when a client sends
|
|
// SSLRequest. tls.go provides helpers for loading a cert/key
|
|
// pair or auto-generating a self-signed pair for dev.
|
|
TLSConfig *tls.Config
|
|
|
|
// ParameterStatus values broadcast right after auth. Five
|
|
// announces itself as PostgreSQL 14 by default so probing
|
|
// clients (pgx, JDBC) negotiate without erroring out on
|
|
// unsupported features.
|
|
ServerVersion string // default "14.0 (FiveSql2)"
|
|
}
|
|
|
|
func (c *Config) listenAddr() string {
|
|
if c.Listen == "" {
|
|
return ":5432"
|
|
}
|
|
return c.Listen
|
|
}
|
|
|
|
func (c *Config) maxConns() int {
|
|
if c.MaxConnections <= 0 {
|
|
return 100
|
|
}
|
|
return c.MaxConnections
|
|
}
|
|
|
|
func (c *Config) serverVersion() string {
|
|
if c.ServerVersion == "" {
|
|
return "14.0 (FiveSql2)"
|
|
}
|
|
return c.ServerVersion
|
|
}
|
|
|
|
// Server is the live server state. Create via NewServer and drive
|
|
// with Serve(); Close() drains in-flight connections gracefully.
|
|
type Server struct {
|
|
vm *hbrt.VM
|
|
cfg Config
|
|
|
|
listener net.Listener
|
|
sem chan struct{} // accept gate, sized to MaxConnections
|
|
|
|
mu sync.Mutex
|
|
conns map[*session]struct{} // live sessions for clean shutdown
|
|
closed bool
|
|
closeCh chan struct{}
|
|
}
|
|
|
|
// NewServer constructs an unstarted Server. The hbrt.VM is the
|
|
// runtime instance whose `FIVE_SQL` function will execute incoming
|
|
// queries — every accepted connection spawns a fresh thread on this
|
|
// VM via vm.NewThread() so statics + workarea factory are inherited
|
|
// (see hbrt/vm.go NewThread, fixed in cde8673 to propagate both).
|
|
func NewServer(vm *hbrt.VM, cfg Config) *Server {
|
|
return &Server{
|
|
vm: vm,
|
|
cfg: cfg,
|
|
sem: make(chan struct{}, cfg.maxConns()),
|
|
conns: make(map[*session]struct{}),
|
|
closeCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Serve binds the listener and runs the accept loop until Close()
|
|
// fires or a fatal accept error is hit. Each accepted connection
|
|
// runs handleConn in its own goroutine.
|
|
func (s *Server) Serve(ctx context.Context) error {
|
|
if s.vm == nil {
|
|
return fmt.Errorf("pgserver: nil VM")
|
|
}
|
|
ln, err := net.Listen("tcp", s.cfg.listenAddr())
|
|
if err != nil {
|
|
return fmt.Errorf("pgserver: listen %s: %w", s.cfg.listenAddr(), err)
|
|
}
|
|
s.mu.Lock()
|
|
s.listener = ln
|
|
s.mu.Unlock()
|
|
|
|
// Stop the listener when ctx cancels so Accept() returns.
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-s.closeCh:
|
|
}
|
|
_ = ln.Close()
|
|
}()
|
|
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
s.mu.Lock()
|
|
closed := s.closed
|
|
s.mu.Unlock()
|
|
if closed {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("pgserver: accept: %w", err)
|
|
}
|
|
// Backpressure: block here when MaxConnections sessions are
|
|
// already in flight. Holding the slot through handleConn
|
|
// (released in defer) is the natural way to gate.
|
|
s.sem <- struct{}{}
|
|
go func(c net.Conn) {
|
|
defer func() { <-s.sem }()
|
|
s.handleConn(ctx, c)
|
|
}(conn)
|
|
}
|
|
}
|
|
|
|
// Close signals the accept loop to exit, drops the listener, and
|
|
// closes every in-flight connection. Outstanding handleConn
|
|
// goroutines exit on the first failed Receive.
|
|
func (s *Server) Close() error {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.closed = true
|
|
ln := s.listener
|
|
conns := make([]*session, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.mu.Unlock()
|
|
close(s.closeCh)
|
|
if ln != nil {
|
|
_ = ln.Close()
|
|
}
|
|
for _, c := range conns {
|
|
_ = c.conn.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleConn is the entry point for each accepted connection.
|
|
// The full implementation (TLS upgrade, startup handshake, auth,
|
|
// query loop) lives in session.go; this function just allocates
|
|
// the session and tracks it for clean shutdown.
|
|
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
|
|
sess := newSession(s, conn)
|
|
s.mu.Lock()
|
|
s.conns[sess] = struct{}{}
|
|
s.mu.Unlock()
|
|
defer func() {
|
|
s.mu.Lock()
|
|
delete(s.conns, sess)
|
|
s.mu.Unlock()
|
|
_ = conn.Close()
|
|
}()
|
|
sess.run(ctx)
|
|
}
|