v2.0.0: error-channel APIs, correctness fixes, and server hardening

BREAKING CHANGES:
- Guard interface: Guard(ctx Ctx) error; returning error short-circuits
  (subsequent guards and the method no longer execute)
- Wired[T]() (*T, error); New() may return error; failed wiring is sticky
- BindService/BindGuard/BindRoute return error; routes accept guards
  (guards receive merged query/form params as Ctx.State)
- registration panics after Start; Ctx.Ip honors X-Forwarded-For/X-Real-IP
- internal errors sanitized to fixed client messages

FIXES:
- int64 precision loss: /cell data decoded from raw JSON bytes and
  responses serialized with json.Number (no float64 round-trip)
- enum values range-checked before uint8 conversion (256 no longer
  truncates to 0 and slips through)
- logger: files failing name parsing are no longer deleted; log channel
  never blocks request goroutines; ConfigLogger is race-free;
  logWriterWorker unlock bug fixed
- stream writer panics recovered (process no longer crashes);
  streamDone closed exactly once
- generated Go client emits definitions for pointer-to-enum/struct fields
- anonymous structs rejected at registration; isPrivate safe on empty names
- removed dead Result.Id and RequestInfo.Type

ADDITIONS:
- graceful shutdown (Shutdown), StartOn, server timeouts by default
  (Read 60s/Idle 120s/Write off), SetTimeouts/SetMaxConcurrency
- big-int-safe JSON in the generated TS client (>2^53 as BigInt)
- example/ demo services and cmd/genexample artifact generator
This commit is contained in:
2026-09-03 15:32:07 +08:00
parent 9019798382
commit 41df889a1a
24 changed files with 1356 additions and 214 deletions
+343
View File
@@ -0,0 +1,343 @@
package fun
// 硬化批次回归测试:真实 IP、路由 Guard、started 护栏与优雅停机、
// 内部错误脱敏、流式 writer panic 兜底、匿名 struct 拒绝、TS 大整数往返
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/valyala/fasthttp"
)
// ---- 真实 IPX-Forwarded-For > X-Real-IP > RemoteAddr ----
func TestClientIP(t *testing.T) {
mk := func(headers map[string]string, remote string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
for k, v := range headers {
fc.Request.Header.Set(k, v)
}
if remote != "" {
fc.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(remote), Port: 1234})
}
return fc
}
if got := clientIP(mk(map[string]string{"X-Forwarded-For": "1.1.1.1, 2.2.2.2"}, "")); got != "2.2.2.2" {
t.Fatalf("XFF last segment: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "3.3.3.3"}, "")); got != "3.3.3.3" {
t.Fatalf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(nil, "9.9.9.9")); got != "9.9.9.9" {
t.Fatalf("RemoteAddr fallback: got %q", got)
}
if got := clientIP(mk(nil, "")); got != "127.0.0.1" {
t.Fatalf("no remote addr: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "::1"}, "")); got != "127.0.0.1" {
t.Fatalf("loopback normalize: got %q", got)
}
}
type EchoIpSvc struct {
Ctx
}
func (s *EchoIpSvc) Get() (string, error) { return s.Ip, nil }
func TestClientIPEndtoEnd(t *testing.T) {
f := New()
if err := f.BindService(&EchoIpSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39015)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
req, err := http.NewRequest("POST", "http://127.0.0.1:39015/cell",
strings.NewReader(`{"serviceName":"EchoIpSvc","methodName":"Get"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-For", "203.0.113.9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out Result[any]
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Status != 0 || out.Data == nil || (*out.Data).(string) != "203.0.113.9" {
t.Fatalf("client ip not honored: %+v", out)
}
}
// ---- 路由 Guard:短路 + 查询参数进 State ----
type RouteTokenGuard struct{}
func (g *RouteTokenGuard) Guard(ctx Ctx) error {
if ctx.State["token"] != "ok" {
return Error(4401, "unauthorized")
}
return nil
}
func TestRouteGuard(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
handlerRan := false
open := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("open")
return nil
}
secret := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("secret")
return nil
}
files := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("file:" + c.Wildcard)
return nil
}
if err := f.BindRoute("GET", "/open", open, &OrderFirstGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/secret", secret, &OrderRejectGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/file/*", files, &RouteTokenGuard{}); err != nil {
t.Fatal(err)
}
do := func(path string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
fc.Request.Header.SetMethod("GET")
fc.Request.SetRequestURI(path)
f.handle(fc)
return fc
}
handlerRan = false
if fc := do("/open"); !handlerRan || string(fc.Response.Body()) != "open" {
t.Fatalf("passing guard: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
fc := do("/secret")
if handlerRan {
t.Fatal("handler must not run when guard rejects")
}
if body := string(fc.Response.Body()); !strings.Contains(body, `"code":4003`) || !strings.Contains(body, `"status":2`) {
t.Fatalf("guard rejection body: %s", body)
}
// 通配路由 + Guard 从查询参数取 tokenState 合并)
handlerRan = false
if fc := do("/file/a/b.txt?token=ok"); !handlerRan || string(fc.Response.Body()) != "file:a/b.txt" {
t.Fatalf("wildcard with token: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
if fc := do("/file/a.txt"); handlerRan || !strings.Contains(string(fc.Response.Body()), `"code":4401`) {
t.Fatal("missing token must be rejected by route guard")
}
}
// ---- started 护栏 + 优雅停机 ----
func TestStartedGuardAndShutdown(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := uint16(ln.Addr().(*net.TCPAddr).Port)
_ = ln.Close()
go f.Start(port)
time.Sleep(300 * time.Millisecond)
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("shutdown: %v", err)
}
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("second shutdown should be no-op: %v", err)
}
catch := func(fn func()) (msg string) {
defer func() { msg = fmt.Sprint(recover()) }()
fn()
return ""
}
if m := catch(func() { _ = f.BindService(&TestSvc{}) }); m == "" {
t.Fatal("BindService after Start must panic")
}
if m := catch(func() { _ = f.BindRoute("GET", "/x", func(*RouteCtx) error { return nil }) }); m == "" {
t.Fatal("BindRoute after Start must panic")
}
if m := catch(func() { f.SetTimeouts(time.Second, 0, time.Second) }); m == "" {
t.Fatal("SetTimeouts after Start must panic")
}
}
// ---- 内部错误脱敏:客户端只收固定提示,不泄露 Go 内部细节 ----
func TestInternalErrorsSanitized(t *testing.T) {
f := New()
if err := f.BindService(&PrecisionSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39016)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
post := func(body string) string {
t.Helper()
resp, err := http.Post("http://127.0.0.1:39016/cell", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// data 字段类型错误(字符串进 int64)→ 通用提示,不泄露 unmarshal/类型名
got := post(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":"not-a-number"}}`)
if !strings.Contains(got, "invalid request data") {
t.Fatalf("expected sanitized message, got: %s", got)
}
for _, leak := range []string{"unmarshal", "int64", "PrecisionDto", "Go struct"} {
if strings.Contains(got, leak) {
t.Fatalf("internal detail %q leaked: %s", leak, got)
}
}
// 损坏的请求体 → 通用提示
got = post(`{"serviceName":`)
if !strings.Contains(got, "invalid request body") {
t.Fatalf("expected sanitized body message, got: %s", got)
}
}
// ---- 流式 writer panic 兜底:进程不崩、服务器仍可用 ----
type boomMarshaler struct{}
func (boomMarshaler) MarshalJSON() ([]byte, error) { panic("boom-json") }
type PanicStreamSvc struct{}
func (s *PanicStreamSvc) Go() (*Stream, error) {
st := &Stream{}
go func() {
_ = st.Send(boomMarshaler{})
st.Close()
}()
return st, nil
}
func TestStreamWriterPanicRecovered(t *testing.T) {
f := New()
if err := f.BindService(&PanicStreamSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39017)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
resp, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("first stream request: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
// panic 被兜底后服务器必须仍然可用
resp2, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("server died after stream writer panic: %v", err)
}
_, _ = io.Copy(io.Discard, resp2.Body)
_ = resp2.Body.Close()
}
// ---- 匿名 struct 注册期拒绝 / isPrivate 空名安全 ----
func TestAnonymousStructRejected(t *testing.T) {
type withAnon struct {
Inner struct{ A string }
}
defer func() {
if recover() == nil {
t.Fatal("anonymous struct must be rejected at registration")
}
}()
checkType(reflect.TypeFor[withAnon]())
_ = isPrivate("") // 空名不再越界 panic
}
// ---- TS 客户端大整数往返(node 真实执行生成物) ----
func TestTypeScriptBigIntRoundTrip(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
const script = `
import { Client } from "./client.ts";
// 响应侧:mock 返回原文 JSON(不经 JS number),大整数必须解析为 BigInt
globalThis.fetch = async () => new Response('{"status":0,"data":{"id":9007199254740993}}', {
headers: { "Content-Type": "application/json" },
});
const client = new Client("http://example.test");
const result = await client.request("Svc", "BigInt");
if (typeof result.data.id !== "bigint") throw new Error("expected bigint, got " + typeof result.data.id);
if (result.data.id !== 9007199254740993n) throw new Error("bigint value lost");
// 请求侧:dto 里的 BigInt 序列化为数字字面量;普通数值不受影响
let requestBody;
globalThis.fetch = async (_url, init) => {
requestBody = init.body;
return new Response('{"status":0}', { headers: { "Content-Type": "application/json" } });
};
await client.request("Svc", "BigInt", { id: 9007199254740993n });
if (!requestBody.includes('"id":9007199254740993')) throw new Error("bigint not serialized: " + requestBody);
await client.request("Svc", "Small", { id: 42 });
if (!requestBody.includes('"id":42')) throw new Error("small int broken: " + requestBody);
`
scriptPath := filepath.Join(dir, "bigint.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
if output, err := exec.Command(node, "--experimental-strip-types", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("bigint round trip failed: %v\n%s", err, output)
}
}