Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/pr-test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@ jobs:
- name: Run full tests with baseline
run: |
mkdir -p target
go test -json ./... > target/test-baseline.json
go test ./... > target/test-baseline.txt
set +e
go test -json -count=1 ./... 2>&1 | tee target/test-baseline.json
Comment on lines +49 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does go test -jsonguarantee JSON events on stdout while non-JSON diagnostics from thego tool may still be written to stderr?

💡 Result:

Yes.

  • go test -json routes the test run through go tool test2json, which “writes a corresponding stream of JSON events to standard output.” The JSON events include the test binary’s output (its stdout+stderr are merged into TestEvent.Output). [1], [2]
  • Separately, the go command’s own diagnostics (for example, failures “building the tests”) are written to the go command’s standard error, which is “reserved for printing errors building the tests.” So non-JSON diagnostics from the go tool may still appear on stderr even when stdout is a JSON event stream. [3]

Sources: [1] cmd/test2json docs, [2] Go 1.10 release notes, [3] cmd/go/internal/test docs/source.


Keep stderr out of test-baseline.json.

2>&1 | tee target/test-baseline.json redirects the go command's own diagnostics (e.g., build errors) into the artifact, corrupting it with non-JSON content. go test -json sends JSON events to stdout only; remove 2>&1 so stderr stays in the job log where it should be.

🔧 Proposed fix
-          go test -json -count=1 ./... 2>&1 | tee target/test-baseline.json
+          go test -json -count=1 ./... | tee target/test-baseline.json
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
set +e
go test -json -count=1 ./... 2>&1 | tee target/test-baseline.json
set +e
go test -json -count=1 ./... | tee target/test-baseline.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/pr-test-build.yml around lines 49 - 50, The pipeline is
redirecting stderr into the JSON artifact; update the command that runs tests by
removing the stderr redirection so only stdout (the JSON events) are piped to
tee. Specifically, change the line invoking "go test -json -count=1 ./... 2>&1 |
tee target/test-baseline.json" to omit "2>&1" so "go test -json -count=1 ./... |
tee target/test-baseline.json" (leave the surrounding "set +e" behavior
unchanged).

test_exit=${PIPESTATUS[0]}
set -e
# Fail the step if tests failed
exit "${test_exit}"
- name: Upload baseline artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: go-test-baseline
path: |
target/test-baseline.json
target/test-baseline.txt
if-no-files-found: error
path: target/test-baseline.json
Comment on lines 59 to +60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

CI and local baseline generation now diverge.

Taskfile.yml:91-96 still produces both test-baseline.json and test-baseline.txt via two separate go test invocations, while this workflow now uploads only the JSON artifact from a single uncached run. That makes local reproduction materially different from CI when debugging failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/pr-test-build.yml around lines 59 - 60, The CI and local
baseline generation diverge because Taskfile.yml still runs two go test
invocations and writes both test-baseline.json and test-baseline.txt, while the
workflow job named go-test-baseline only uploads test-baseline.json from a
single uncached run; make them consistent by either (a) updating the workflow
job go-test-baseline to run the same Taskfile target(s) that produce both
test-baseline.json and test-baseline.txt and upload both artifacts (add path for
test-baseline.txt alongside test-baseline.json), or (b) change Taskfile.yml to
mirror CI’s single uncached go test run and only emit test-baseline.json; pick
one approach and apply it so Taskfile.yml, the go-test-baseline job, and the
produced artifacts (test-baseline.json, test-baseline.txt) match.

if-no-files-found: warn

quality-ci:
name: quality-ci
Expand Down
24 changes: 18 additions & 6 deletions pkg/llmproxy/logging/gin_logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,32 @@ func isAIAPIPath(path string) bool {
// Returns:
// - gin.HandlerFunc: A middleware handler for panic recovery
func GinLogrusRecovery() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
// Let net/http handle ErrAbortHandler so the connection is aborted without noisy stack logs.
panic(http.ErrAbortHandler)
}
return gin.CustomRecovery(ginLogrusRecoveryFunc)
}

// ginLogrusRecoveryFunc is the recovery callback used by GinLogrusRecovery.
// It re-panics http.ErrAbortHandler so net/http can abort the connection cleanly,
// and logs + returns 500 for all other panics.
func ginLogrusRecoveryFunc(c *gin.Context, recovered interface{}) {
if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
// Let net/http handle ErrAbortHandler so the connection is aborted without noisy stack logs.
panic(http.ErrAbortHandler)
}

if c != nil && c.Request != nil {
log.WithFields(log.Fields{
"panic": recovered,
"stack": string(debug.Stack()),
"path": c.Request.URL.Path,
}).Error("recovered from panic")

c.AbortWithStatus(http.StatusInternalServerError)
})
} else {
log.WithFields(log.Fields{
"panic": recovered,
"stack": string(debug.Stack()),
}).Error("recovered from panic")
}
}

// SkipGinRequestLogging marks the provided Gin context so that GinLogrusLogger
Expand Down
52 changes: 27 additions & 25 deletions pkg/llmproxy/logging/gin_logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,37 @@ import (
)

func TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {
// Test the recovery logic directly: gin.CustomRecovery's internal recovery
// handling varies across platforms (macOS vs Linux) and Go versions, so we
// invoke the recovery callback that GinLogrusRecovery passes to
// gin.CustomRecovery and verify it re-panics ErrAbortHandler.
gin.SetMode(gin.TestMode)

engine := gin.New()
engine.Use(GinLogrusRecovery())
engine.GET("/abort", func(c *gin.Context) {
panic(http.ErrAbortHandler)
})

req := httptest.NewRequest(http.MethodGet, "/abort", nil)
recorder := httptest.NewRecorder()

defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("expected panic, got nil")
}
err, ok := recovered.(error)
if !ok {
t.Fatalf("expected error panic, got %T", recovered)
}
if !errors.Is(err, http.ErrAbortHandler) {
t.Fatalf("expected ErrAbortHandler, got %v", err)
}
if err != http.ErrAbortHandler {
t.Fatalf("expected exact ErrAbortHandler sentinel, got %v", err)
}
var repanicked bool
var repanic interface{}

func() {
defer func() {
if r := recover(); r != nil {
repanicked = true
repanic = r
}
}()
// Simulate what gin.CustomRecovery does: call the recovery func
// with the recovered value.
ginLogrusRecoveryFunc(nil, http.ErrAbortHandler)
}()

engine.ServeHTTP(recorder, req)
if !repanicked {
t.Fatalf("expected ginLogrusRecoveryFunc to re-panic http.ErrAbortHandler, but it did not")
}
err, ok := repanic.(error)
if !ok {
t.Fatalf("expected error panic, got %T", repanic)
}
if !errors.Is(err, http.ErrAbortHandler) {
t.Fatalf("expected ErrAbortHandler, got %v", err)
}
Comment on lines 12 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Gin 1.10.1, if a gin.CustomRecoverycallback re-panics, is that panic guaranteed to be observable by an outer middleware that invokes the returned handler directly instead of registering it withengine.Use?

💡 Result:

Yes — the re-panic will propagate out of the gin.CustomRecovery-returned handler to its direct caller, so an “outer” middleware that wraps and directly invokes that handler can observe (and recover) the panic.

In Gin v1.10.1, the recovery middleware does recover() in a deferred function and then calls your handle(c, err) without any additional recover around that call; so if handle panics, that panic is not caught by Gin’s recovery middleware and unwinds to the caller of the middleware handler. (raw.githubusercontent.com)

If nothing in your stack recovers it, the panic may still be recovered by Go’s net/http server boundary (logging a stack trace and closing/resetting the stream), but that’s outside Gin. (pkg.go.dev)

Citations:


Extract the recovery callback into a testable helper function.

The current test exceeds the 40-line function length limit (41 lines) and remains coupled to Gin's panic propagation semantics, which has introduced a failure in CI. While gin.CustomRecovery callbacks do propagate re-panics to their direct caller, routing through engine.ServeHTTP introduces additional panic recovery boundaries that prevent the outer middleware from observing the panic.

Extract the recovery logic into a standalone handleRecoveredPanic(c *gin.Context, recovered interface{}) helper and test it directly with a minimal test context instead of routing through the full HTTP server pipeline. This isolates the behavior you actually care about, eliminates framework complexity, and satisfies the maximum function length guideline.

🔧 Proposed refactor
diff --git a/pkg/llmproxy/logging/gin_logger.go b/pkg/llmproxy/logging/gin_logger.go
@@
+func handleRecoveredPanic(c *gin.Context, recovered interface{}) {
+	if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
+		panic(http.ErrAbortHandler)
+	}
+
+	log.WithFields(log.Fields{
+		"panic": recovered,
+		"stack": string(debug.Stack()),
+		"path":  c.Request.URL.Path,
+	}).Error("recovered from panic")
+
+	c.AbortWithStatus(http.StatusInternalServerError)
+}
+
 func GinLogrusRecovery() gin.HandlerFunc {
-	return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
-		if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
-			panic(http.ErrAbortHandler)
-		}
-
-		log.WithFields(log.Fields{
-			"panic": recovered,
-			"stack": string(debug.Stack()),
-			"path":  c.Request.URL.Path,
-		}).Error("recovered from panic")
-
-		c.AbortWithStatus(http.StatusInternalServerError)
-	})
+	return gin.CustomRecovery(handleRecoveredPanic)
 }
diff --git a/pkg/llmproxy/logging/gin_logger_test.go b/pkg/llmproxy/logging/gin_logger_test.go
@@
 func TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {
-	// Test the recovery handler directly to avoid platform-dependent behavior
-	// in gin's ServeHTTP panic propagation (macOS vs Linux differ).
-	handler := GinLogrusRecovery()
-
 	gin.SetMode(gin.TestMode)
-	engine := gin.New()
-
-	var repanicked bool
-	var repanic interface{}
-
-	// Wrap the recovery middleware to intercept the re-panic before gin's
-	// outer recovery can swallow it.
-	engine.Use(func(c *gin.Context) {
-		defer func() {
-			if r := recover(); r != nil {
-				repanicked = true
-				repanic = r
-			}
-		}()
-		handler(c)
-	})
-	engine.GET("/abort", func(c *gin.Context) {
-		panic(http.ErrAbortHandler)
-	})
-
-	req := httptest.NewRequest(http.MethodGet, "/abort", nil)
-	recorder := httptest.NewRecorder()
-
-	engine.ServeHTTP(recorder, req)
-
-	if !repanicked {
-		t.Fatalf("expected GinLogrusRecovery to re-panic http.ErrAbortHandler, but it did not")
-	}
-	err, ok := repanic.(error)
-	if !ok {
-		t.Fatalf("expected error panic, got %T", repanic)
-	}
-	if !errors.Is(err, http.ErrAbortHandler) {
-		t.Fatalf("expected ErrAbortHandler, got %v", err)
-	}
+	c, _ := gin.CreateTestContext(httptest.NewRecorder())
+	c.Request = httptest.NewRequest(http.MethodGet, "/abort", nil)
+
+	defer func() {
+		r := recover()
+		err, ok := r.(error)
+		if !ok || !errors.Is(err, http.ErrAbortHandler) {
+			t.Fatalf("expected ErrAbortHandler, got %T (%v)", r, r)
+		}
+	}()
+
+	handleRecoveredPanic(c, http.ErrAbortHandler)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
// Test the recovery handler directly to avoid platform-dependent behavior
// in gin's ServeHTTP panic propagation (macOS vs Linux differ).
handler := GinLogrusRecovery()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(GinLogrusRecovery())
var repanicked bool
var repanic interface{}
// Wrap the recovery middleware to intercept the re-panic before gin's
// outer recovery can swallow it.
engine.Use(func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
repanicked = true
repanic = r
}
}()
handler(c)
})
engine.GET("/abort", func(c *gin.Context) {
panic(http.ErrAbortHandler)
})
req := httptest.NewRequest(http.MethodGet, "/abort", nil)
recorder := httptest.NewRecorder()
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("expected panic, got nil")
}
err, ok := recovered.(error)
if !ok {
t.Fatalf("expected error panic, got %T", recovered)
}
if !errors.Is(err, http.ErrAbortHandler) {
t.Fatalf("expected ErrAbortHandler, got %v", err)
}
if err != http.ErrAbortHandler {
t.Fatalf("expected exact ErrAbortHandler sentinel, got %v", err)
}
}()
engine.ServeHTTP(recorder, req)
if !repanicked {
t.Fatalf("expected GinLogrusRecovery to re-panic http.ErrAbortHandler, but it did not")
}
err, ok := repanic.(error)
if !ok {
t.Fatalf("expected error panic, got %T", repanic)
}
if !errors.Is(err, http.ErrAbortHandler) {
t.Fatalf("expected ErrAbortHandler, got %v", err)
}
func TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/abort", nil)
defer func() {
r := recover()
err, ok := r.(error)
if !ok || !errors.Is(err, http.ErrAbortHandler) {
t.Fatalf("expected ErrAbortHandler, got %T (%v)", r, r)
}
}()
handleRecoveredPanic(c, http.ErrAbortHandler)
}
🧰 Tools
🪛 GitHub Actions: pr-test-build

[error] 44-51: GinLogrusRecovery: expected re-panic of http.ErrAbortHandler, but it did not

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/llmproxy/logging/gin_logger_test.go` around lines 12 - 52, Extract the
core recovery behavior into a helper handleRecoveredPanic(c *gin.Context,
recovered interface{}) and update GinLogrusRecovery to call that helper; then
replace the current TestGinLogrusRecoveryRepanicsErrAbortHandler with a test
that directly calls handleRecoveredPanic using a minimal test gin.Context (or a
stubbed context) to assert that http.ErrAbortHandler is re-panicked, removing
the need to run engine.ServeHTTP and keeping the test under 40 lines. Ensure
GinLogrusRecovery still uses the helper so production behavior is unchanged and
the new test only exercises handleRecoveredPanic for deterministic,
framework-independent verification.

}

func TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) {
Expand Down
Loading