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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user