feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 18:51:17 +02:00
commit a9b8039ef7
153 changed files with 34403 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package server
import (
"os"
"syscall"
"testing"
)
// The transient accept errors are survivable; anything else is not.
func TestTransientAccept(t *testing.T) {
transient := []error{
os.NewSyscallError("accept", syscall.ECONNABORTED),
os.NewSyscallError("accept", syscall.EMFILE),
os.NewSyscallError("accept", syscall.ENFILE),
os.NewSyscallError("accept", syscall.EAGAIN),
os.NewSyscallError("accept", syscall.EINTR),
}
for _, err := range transient {
if !transientAccept(err) {
t.Fatalf("%v classified as fatal", err)
}
}
if transientAccept(os.NewSyscallError("accept", syscall.EACCES)) {
t.Fatal("EACCES classified as transient")
}
if transientAccept(os.ErrClosed) {
t.Fatal("a closed listener classified as transient")
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Package server accepts connections from NFS clients.
//
// Each accepted connection reaches the Handle hook, which owns the
// connection for its whole lifetime; a nil Handle closes it at once. The
// nfsd command wires the hook to the NFSv4.2 dispatcher.
package server
import (
"context"
"errors"
"net"
"sync/atomic"
"syscall"
"time"
)
// acceptRetryPause is the pause before the next accept after a
// transient resource error, so a connection storm cannot spin the loop.
const acceptRetryPause = 10 * time.Millisecond
// A Server accepts connections from NFS clients.
type Server struct {
// Handle serves one accepted connection. It runs on its own goroutine
// and owns the connection for its whole lifetime, closing it when the
// session ends. A nil Handle closes the connection at once.
Handle func(conn net.Conn)
// MaxConns caps the connections served at once. Zero means no cap. A
// connection offered above the cap closes at once, and the client
// sees an immediate end of file.
MaxConns int
live atomic.Int64
}
// transientAccept reports whether the accept error is survivable: the
// listener stays usable and the next accept is worth trying. A storm of
// aborted connections or a momentary file table exhaustion must not
// take the daemon down with every client on it.
func transientAccept(err error) bool {
return errors.Is(err, syscall.ECONNABORTED) ||
errors.Is(err, syscall.EMFILE) ||
errors.Is(err, syscall.ENFILE) ||
errors.Is(err, syscall.EAGAIN) ||
errors.Is(err, syscall.EINTR)
}
// Serve accepts connections on ln until the listener fails or ctx is
// cancelled. A cancellation closes the listener and Serve returns nil;
// a closed listener returns nil; a transient accept error is waited out;
// any other listener failure returns the error as is. In flight
// connections are not drained: NFS clients retry through their session
// replay caches, so an immediate return is the correct shutdown.
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
ln.Close()
case <-stop:
}
}()
for {
conn, err := ln.Accept()
if err == nil {
if s.Handle == nil {
conn.Close()
continue
}
if s.MaxConns > 0 && s.live.Load() >= int64(s.MaxConns) {
conn.Close()
continue
}
s.live.Add(1)
go func() {
defer s.live.Add(-1)
s.Handle(conn)
}()
continue
}
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return nil
}
if transientAccept(err) {
time.Sleep(acceptRetryPause)
continue
}
return err
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package server
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
)
func dial(t *testing.T, addr string) net.Conn {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial %s: %v", addr, err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
func TestServeHandsConnectionsToHandle(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
accepted := make(chan net.Conn, 2)
s := &Server{Handle: func(conn net.Conn) {
accepted <- conn
conn.Close()
}}
ctx, cancel := context.WithCancel(context.Background())
serveErr := make(chan error, 1)
go func() { serveErr <- s.Serve(ctx, ln) }()
for range 2 {
conn := dial(t, ln.Addr().String())
select {
case got := <-accepted:
if got.RemoteAddr() == nil {
t.Fatal("a connection without a remote address arrived")
}
case <-time.After(2 * time.Second):
t.Fatal("the connection did not reach Handle")
}
// Handle closed the connection, so the next read reports it.
if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("read after Handle returned: %v", err)
}
}
cancel()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned %v after cancellation", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Serve did not return after cancellation")
}
}
func TestServeClosesWithoutHandle(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s := &Server{}
ctx, cancel := context.WithCancel(context.Background())
serveErr := make(chan error, 1)
go func() { serveErr <- s.Serve(ctx, ln) }()
conn := dial(t, ln.Addr().String())
if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("a connection without Handle read %v, want EOF", err)
}
cancel()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned %v after cancellation", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Serve did not return after cancellation")
}
}
func TestServeListenerFailure(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
if err := ln.Close(); err != nil {
t.Fatalf("close: %v", err)
}
// A closed listener is a stop, not a failure: the shutdown raced the
// accept loop, and the daemon exits cleanly either way.
s := &Server{}
if err := s.Serve(context.Background(), ln); err != nil {
t.Fatalf("Serve returned %v for a closed listener, want nil", err)
}
}
func TestServeCapsConnections(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
// The first connection holds its slot until released, so the state of
// the cap is deterministic for the second.
release := make(chan struct{})
s := &Server{MaxConns: 1, Handle: func(conn net.Conn) {
<-release
conn.Close()
}}
ctx, cancel := context.WithCancel(context.Background())
serveErr := make(chan error, 1)
go func() { serveErr <- s.Serve(ctx, ln) }()
first := dial(t, ln.Addr().String())
// The refused connection ends at once: the read sees the close, not a
// timeout.
refused := dial(t, ln.Addr().String())
refused.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := refused.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("a connection above the cap read %v, want the immediate end", err)
}
close(release)
first.Close()
cancel()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned %v after cancellation", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Serve did not return after cancellation")
}
}