Skip to content

Pre-tinygo optimizations and codebase cleanups#113

Merged
gosunuts merged 16 commits intogosuda:mainfrom
metaphorics:perf/wasm-optimize
Feb 15, 2026
Merged

Pre-tinygo optimizations and codebase cleanups#113
gosunuts merged 16 commits intogosuda:mainfrom
metaphorics:perf/wasm-optimize

Conversation

@metaphorics
Copy link
Contributor

@metaphorics metaphorics commented Dec 30, 2025

This pull request introduces several improvements and optimizations across the codebase. The most significant changes include major performance optimizations for buffer pooling, a refactored WebAssembly (WASM) production build process, a migration to reflection-free protobufs for TinyGo compatibility, and enhanced developer tooling for code quality and consistency.

Performance and Memory Optimizations:

  • Updated buffer pools in both cmd/portal-tunnel/main.go and cmd/relay-server/manager/bps_manager.go to use pointers to byte slices (*[]byte) instead of slices directly, reducing interface boxing allocations and improving performance under high concurrency. [1] [2] [3] [4]

WebAssembly (WASM) Build Improvements:

  • Enhanced the build-wasm target in the Makefile to produce production-ready WASM builds by stripping debug symbols, enabling aggressive inlining, setting reproducible build IDs, and adding a prod build tag to disable debugging and HTML injection. Also improved WASM optimization by running a dual-pass with wasm-opt for both speed and size.
  • Added Go build constraints (//go:build !prod) to cmd/webclient/inject.go and cmd/webclient/main_js.go to exclude debugging and injection code from production builds. [1] [2]

Protobuf and Protocol Changes:

  • Migrated portal/core/proto/rdsec/rdsec.pb.go to a reflection-free, TinyGo-compatible protobuf implementation by removing dependencies on protoimpl and protoreflect, simplifying message types, and updating serialization/deserialization code in portal/core/cryptoops/handshaker.go to use MarshalVT/UnmarshalVT methods. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12]

Developer Tooling and Code Quality:

  • Added a .pre-commit-config.yaml with hooks for whitespace cleanup, line ending normalization, Go formatting, and configuration verification, as well as a new justfile with common formatting, linting, testing, and vetting commands to streamline development workflows. [1] [2]

Dependency Updates:

  • Updated several module dependencies in go.mod, including golang.org/x/crypto, golang.org/x/net, google.golang.org/protobuf, gopkg.eu.org/broccoli, and indirect dependencies, to newer versions for improved security and compatibility.

Note

High Risk
Touches security- and correctness-critical areas (handshake/protocol serialization) and introduces a new prod-specific WASM entrypoint, so regressions could break connectivity or client behavior in production.

Overview
Pre-TinyGo build + runtime optimizations across the Go/WASM client and core protocol code.

WASM builds now have a production mode (-tags=prod) with a new cmd/webclient/main_js_prod.go (no logging, no HTML injection, includes a websocket polyfill proxy + SDK message handler) and an updated Makefile build-wasm target that strips symbols, improves reproducibility, and runs a dual-pass wasm-opt optimization.

Protocol serialization and runtime performance are tightened: rdsec/rdverb generated protos are replaced with reflection-free variants and handshake code switches from proto.Marshal/Unmarshal to MarshalVT/UnmarshalVT; several hot-path sync.Pool buffers (tunnel, rate limiters, BPS manager) now pool *[]byte to reduce allocations; WsStream is refactored to be testable (interface-backed conn) and to handle multi-frame reads correctly, with extensive new tests/benchmarks added for protobuf VT methods, rate limiting, wsstream, and utilities. Tooling is also updated via pre-commit config/justfile, and dependencies are bumped in go.mod/go.sum.

Written by Cursor Bugbot for commit f93cd82. This will update automatically on new commits. Configure here.


Open with Devin

- Add //go:build !prod tags to main_js.go and inject.go
- Create main_js_prod.go with //go:build prod tag
- Remove zerolog dependency from production build
- Make InjectHTML a no-op in production (handled by service worker)

This reduces WASM binary size and runtime overhead by excluding
debug logging and HTML parsing from production builds.
- Add -gcflags="-l=4 -trimpath" for aggressive inlining
- Add -ldflags="-s -w -buildid=" to strip symbols and build ID
- Add -tags=prod to enable production-only code path
- Implement dual-pass wasm-opt: -O4 for speed, then -Oz for size
- Enable bulk-memory, sign-ext, and reference-types features

This prioritizes runtime performance over binary size.
The WebSocket dialer returns a response that was never closed,
causing a resource leak. Now closes the response body on error.
Replace dynamic slice growth with pre-allocation using make()
with known capacity. This reduces allocations during relay
iteration.
Change sync.Pool to store *[]byte instead of []byte to prevent
interface boxing allocation on each Get/Put operation.

Applied to:
- cmd/portal-tunnel/main.go
- cmd/relay-server/manager/bps_manager.go
- portal/utils/ratelimit/bucket.go
…y' instead of 'proto.Message'

- Changed the return type of CloneMessageVT methods in various structs from proto.Message to any.
- Updated the parameter type of EqualMessageVT methods to accept 'any' instead of 'proto.Message'.
- This change enhances type flexibility and aligns with recent Go language updates.
- Add rdsec_test.go covering all 4 message types (Identity, ClientInitPayload, SignedPayload, ServerInitPayload)
  - Round-trip MarshalVT/UnmarshalVT tests
  - CloneVT, EqualVT, SizeVT tests
  - Getter methods, Reset methods, and enum String/Enum tests
  - Coverage: 52.5%

- Add rdverb_test.go covering all 11 message types (Packet, RelayInfo, Lease, etc.)
  - All packet type and response code enum tests
  - Getter methods and Reset methods
  - Complex nested message tests
  - Coverage: 36.6%

- Add wsstream_test.go with comprehensive WebSocket stream wrapper tests
  - Mock implementation for isolated testing
  - Concurrent read/write safety tests
  - Multi-message streaming tests
  - Coverage: 93.1%

- Refactor wsstream to use webSocketConn interface for testability
- Update utils/ws.go to use wsstream.New() constructor
- Updated test cases in various files to use range-based loops for better readability and performance.
- Modified the `justfile` to include `tidy` in the `all` target.
- Enhanced `pre-commit-config.yaml` to ensure proper linting and formatting for Go files.
- Added comprehensive tests for rate limiting and WebSocket functionalities in `utils` and `wsstream`.
- Improved error handling and edge case coverage in existing tests.
    Fix flakiness and incorrect assumptions in bucket tests:
    - Trigger lazy state update explicitly in slack refill test
    - Adjust burst/rate ratios to reliably trigger throttling in concurrent tests
    - Increase rate for buffer pool test to avoid timeouts
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@metaphorics metaphorics reopened this Feb 15, 2026
// Current message exhausted, try to get next one
g.currentReader = nil
continue
}
Copy link

Choose a reason for hiding this comment

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

EOF handling drops websocket payload bytes

High Severity

WsStream.Read now loops on io.EOF and immediately retries, which discards n bytes when a reader returns data together with EOF. The same pattern also converts close errors to io.EOF with 0 bytes. This can silently truncate data read from websocket frames.

Additional Locations (1)

Fix in Cursor Fix in Web

s, ok := PacketType_name[int32(x)]
if !ok {
return "PacketType(" + string(rune('0'+x)) + ")"
}
Copy link

Choose a reason for hiding this comment

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

Unknown enum string formatting is incorrect

Low Severity

For unknown enum values, String() now builds text using string(rune('0'+x)), which does not produce decimal numbers and can emit unrelated Unicode characters. This breaks readable fallback formatting for forward-compatible or unexpected enum values.

Additional Locations (2)

Fix in Cursor Fix in Web

"error": err.Error(),
})
return
}
Copy link

Choose a reason for hiding this comment

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

Failed upgrade leaves stale SDK connection

Medium Severity

After storing conn in sdkConnections and sending SDK_CONNECT_SUCCESS, upgrade validation/write failures return early without removing the map entry or closing the connection. This leaves orphaned live connections and inconsistent client state for failed upgradeRequest paths.

Additional Locations (1)

Fix in Cursor Fix in Web

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 15, 2026

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Added HTTP/WebSocket proxy support in production builds with DNS caching and SDK bridge integration.
  • Bug Fixes

    • Fixed resource leak in WebSocket connection handling by ensuring response body cleanup.
    • Improved WebSocket stream reading to handle edge cases and multiple messages gracefully.
  • Performance

    • Optimized WASM build with dual-pass compilation and aggressive inlining.
    • Enhanced buffer allocation strategy in rate limiting and relay management.

Walkthrough

This PR introduces production WASM proxy functionality with SDK bridging, refactors buffer pools across multiple modules for reduced allocations, migrates protocol serialization to VT-based marshalling, adds extensive test coverage for protocol and utility functions, and updates build configuration with production-oriented WASM optimization and justfile automation.

Changes

Cohort / File(s) Summary
Build & Tooling Configuration
.pre-commit-config.yaml, Makefile, justfile
Removes golangci-lint hooks in favor of local gofmt, introduces dual-pass WASM optimization (O4 then Oz), adds production build flags, and introduces justfile with fmt, lint, test, vet, tidy, and all targets.
Production WASM Build
cmd/webclient/inject.go, cmd/webclient/main_js.go, cmd/webclient/main_js_prod.go
Adds build constraints (!prod) to exclude dev files from production builds; introduces new production proxy with HTTP/WebSocket polyfill, SDK bridge handlers, DNS lease caching, and JS interop for bootstrap server discovery and message routing.
Buffer Pool Optimizations
cmd/portal-tunnel/main.go, cmd/relay-server/manager/bps_manager.go, portal/utils/ratelimit/bucket.go
Refactors sync.Pool to store pointers to byte slices (\*[]byte) instead of values, reducing interface boxing allocations in buffer retrieval and return paths.
Protocol Serialization
portal/core/cryptoops/handshaker.go
Replaces protobuf proto marshalling with VT-based MarshalVT/UnmarshalVT calls across handshake serialization paths, eliminating proto package import.
Protocol Test Coverage
portal/core/proto/rdsec/rdsec_test.go, portal/core/proto/rdverb/rdverb_test.go
Adds comprehensive round-trip marshalling, cloning, equality, and size calculation tests for rdsec and rdverb message types, including edge cases, nil handling, and benchmarks.
WebSocket Stream Abstraction
portal/utils/wsstream/wsstream.go, portal/utils/wsstream/wsstream_test.go, utils/ws.go
Introduces webSocketConn interface for dependency injection in WsStream, adds New() factory constructor, reworks Read logic for multi-message handling and graceful EOF conversion, and updates ws.go call sites to use factory function.
Utility Test Coverage
portal/utils/ratelimit/bucket_test.go, utils/utils_test.go
Adds extensive test suites for rate limiter bucket (invalid inputs, concurrent access, error handling) and utility functions (HTTP headers, localhost detection, WebSocket operations, URL parsing).
Dependencies & Minor Optimizations
go.mod, sdk/sdk.go
Updates golang.org/x/crypto, golang.org/x/net, google.golang.org/protobuf, and broccoli to latest patches; pre-sizes relay slices in Dial and LookupName with explicit unlocks.

Sequence Diagram(s)

sequenceDiagram
    participant JS as JavaScript Layer
    participant Proxy as Production Proxy
    participant DNS as DNS/Lease Cache
    participant SDK as SDK Dialer
    participant Upstream as Upstream Server
    
    JS->>Proxy: HTTP/WS Request via __go_jshttp
    Proxy->>Proxy: Parse request & route
    
    alt WebSocket Polyfill
        Proxy->>Proxy: Extract or create connection ID
        Proxy->>DNS: Lookup lease ID for hostname
        DNS->>Proxy: Return lease ID
        Proxy->>SDK: Create/dial connection to upstream
        SDK->>Upstream: Establish connection
        Upstream->>SDK: Connection ready
        SDK->>Proxy: Connection established
        Proxy->>Proxy: Queue connection in WebSocketManager
        Proxy->>JS: Return connection ID (SDK_CONNECT_SUCCESS)
        
        JS->>Proxy: Poll for messages (GET /sw-cgi/websocket/poll/{connID})
        Proxy->>Proxy: Retrieve queued messages
        Proxy->>JS: Return Uint8Array stream (SDK_DATA)
        
        JS->>Proxy: Send data (POST /sw-cgi/websocket/send/{connID})
        Proxy->>SDK: Forward data to connection
        SDK->>Upstream: Send data
    else HTTP Proxy
        Proxy->>DNS: Lookup address for hostname
        DNS->>Proxy: Return resolved address
        Proxy->>SDK: Dial HTTP endpoint
        SDK->>Upstream: Connect & send request
        Upstream->>SDK: Response data
        SDK->>Proxy: Return response
        Proxy->>JS: Response body as Uint8Array
    end
    
    JS->>Proxy: Disconnect (DELETE /sw-cgi/websocket/{connID})
    Proxy->>SDK: Close connection
    SDK->>Upstream: Gracefully close
    Proxy->>Proxy: Remove from WebSocketManager
Loading

Possibly related PRs

Suggested reviewers

  • gosunuts
  • rabbitson87
  • splkm97
  • snowmerak
  • yoonhyunwoo
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main intent of the changeset: performance optimizations and code cleanups targeting pre-TinyGo compatibility.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing performance optimizations, WASM improvements, protobuf migration, and tooling updates.
Docstring Coverage ✅ Passed Docstring coverage is 82.11% which is sufficient. The required threshold is 70.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Member

@gosunuts gosunuts left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

}()

lease, err := client.LookupName(leaseName)
if err != nil {
Copy link

Choose a reason for hiding this comment

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

Production SDK skips lease-name normalization

Medium Severity

handleSDKConnect calls client.LookupName(leaseName) directly in production, omitting the getLeaseID normalization used in the non-prod path. This can break lookups for URL-encoded, punycode, or mixed-case lease names and cause production-only connection failures.

Fix in Cursor Fix in Web

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +58 to +62
if err == io.EOF {
// Current message exhausted, try to get next one
g.currentReader = nil
continue
}

Choose a reason for hiding this comment

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

🔴 WsStream.Read discards data when message reader returns (n > 0, io.EOF)

When currentReader.Read(p) returns both data (n > 0) and io.EOF simultaneously—which is explicitly permitted by the io.Reader contract—the new code discards those n bytes by executing continue, which loops back and overwrites p from position 0 with data from the next WebSocket message.

Root Cause and Impact

The old code at portal/utils/wsstream/wsstream.go correctly handled this case:

n, err = g.currentReader.Read(p)
if err == io.EOF {
    g.currentReader = nil
    err = nil
}
return n, err  // returns (n, nil) — data preserved

The new code at lines 57–62:

n, err = g.currentReader.Read(p)
if err == io.EOF {
    g.currentReader = nil
    continue  // DISCARDS n bytes already written to p!
}

Many io.Reader implementations return the final chunk of data together with io.EOF in a single call (e.g., bytes.Reader, and gorilla/websocket's internal message reader when the entire message fits in the buffer). When this happens, the n bytes already copied into p are silently lost because continue causes the loop to call NextReader() and then Read(p) again, overwriting p from index 0.

Impact: Silent data loss on every WebSocket stream used throughout the relay system (tunnel connections, SDK connections, webclient). This can cause corrupted HTTP responses, broken handshakes, or incomplete data transfers—essentially any communication flowing through WsStream.

Suggested change
if err == io.EOF {
// Current message exhausted, try to get next one
g.currentReader = nil
continue
}
if err == io.EOF {
// Current message exhausted, clear reader for next call
g.currentReader = nil
// If data was read, return it (without the EOF error)
if n > 0 {
return n, nil
}
// No data in this message, try to get next one
continue
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@gosunuts gosunuts merged commit 43c3537 into gosuda:main Feb 15, 2026
4 of 6 checks passed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@cmd/webclient/main_js_prod.go`:
- Around line 425-432: The code uses io.ReadAll on proxied HTML responses (see
utils.IsHTMLContentType, resp.Body and io.ReadAll), which can exhaust WASM
memory; fix by replacing io.ReadAll(resp.Body) with reading from
io.LimitReader(resp.Body, maxHTMLBodyBytes) where maxHTMLBodyBytes is a
reasonable constant (e.g. a few MB), then handle the case where the response is
truncated (log or return a 502/413) before calling
w.WriteHeader(resp.StatusCode) and w.Write; ensure resp.Body is still closed and
errors from reading the limited reader are handled and surfaced to the client.
- Around line 603-607: The empty defer/recover closures in
cmd/webclient/main_js_prod.go (including the one in handleSDKConnect and the
three other similar closures) silently swallow panics; change each defer func()
{ if r := recover(); r != nil {} }() to capture the recovered value and at
minimum log it (e.g., console.Error or the package logger) with contextual
information and a stack trace (use runtime/debug.Stack()) so errors are visible
in production; keep the recover but replace the empty body with a log statement
that includes the recovered value and stack trace, or rethrow/propagate after
logging if the caller must handle it.
- Around line 414-417: The error response leaks an internal resolved URL via
r.URL.String() after calling httpClient.Do(r); remove the internal URL from the
client-facing message and return a generic error (e.g., "Failed to proxy
request") or use the original public host (r.Host or r.Header.Get("Host")) if
you must include a hostname, and separately log the detailed error and
r.URL.String()/err server-side for debugging; update the http.Error call that
currently uses fmt.Sprintf("Failed to proxy request to %s", r.URL.String()) and
add a process/server log of err and r.URL.String() instead.
- Around line 237-240: The dialer.Dial call in cmd/webclient/main_js_prod.go can
return a non-nil resp with a Body even when err != nil; modify the error path
after dialer.Dial(u.String(), nil) to close resp.Body if resp != nil before
returning the error (mirror the pattern used in utils/ws.go's
NewWebSocketDialer). Ensure you still return the original error and do not
swallow it; reference the variables dialer.Dial, resp, conn, and err when making
the change.
- Around line 406-412: The code uses r.Clone(context.Background()) which drops
the incoming request's cancellation; instead preserve the client's context by
cloning with r.Context() (e.g., r = r.Clone(r.Context())) or simply avoid
cloning and modify r.URL directly after reading the request context; ensure the
proxied request uses the original context so cancellation from the browser
propagates when you call getLeaseID and then set r.URL.Host and r.URL.Scheme.
- Around line 654-657: The code currently assigns connID := lease.Identity.Id
which keys sdkConnections by lease ID and causes silent overwrites; replace that
assignment with a call to generateConnID() so each connection gets a unique
identifier before storing it in sdkConnections (the block around
sdkConnectionsMu.Lock/Unlock that sets sdkConnections[connID] = conn). Ensure
connID is generated once per incoming SDK_CONNECT and used consistently for
registration and subsequent bookkeeping so existing connections are not
overwritten.

In `@portal/core/proto/rdsec/rdsec_test.go`:
- Around line 584-626: The test closures in testCases capture the outer
testing.T causing misattributed failures; change the testCases entry type from
"test func()" to "test func(t *testing.T)" and update each closure (for
ClientInitPayload, SignedPayload, ServerInitPayload) to accept a *testing.T
parameter and use that t for calls like t.Errorf/t.Error; finally change the
t.Run invocation to call tc.test(t) (i.e., t.Run(tc.name, func(t *testing.T) {
tc.test(t) })) so subtest failures are reported on the correct *testing.T.
- Around line 678-742: Replace the benchmark loops in
BenchmarkIdentity_MarshalVT, BenchmarkIdentity_UnmarshalVT,
BenchmarkClientInitPayload_MarshalVT, and BenchmarkClientInitPayload_UnmarshalVT
to use the recommended b.Loop() pattern instead of "for range b.N"; keep the
setup and b.ResetTimer() as-is, then change each loop body to "for b.Loop() {
... }" so the calls to msg.MarshalVT() and got.UnmarshalVT(data) execute inside
b.Loop().
- Around line 894-925: The test TestServerInitPayload_Reset currently asserts
msg.Version != ProtocolVersion_PROTOCOL_VERSION_1 which is misleading because
ProtocolVersion_PROTOCOL_VERSION_1 is the zero value; change the assertion to
explicitly check msg.Version == 0 (or != 0 if you intend a non-zero default) and
update the error message accordingly so it clearly asserts that Reset zeroes the
Version (referencing msg.Version and ProtocolVersion_PROTOCOL_VERSION_1 in the
TestServerInitPayload_Reset function).

In `@portal/core/proto/rdverb/rdverb_test.go`:
- Around line 974-1050: Replace the old benchmark loop pattern `for range b.N {
... }` with the new `for b.Loop() { ... }` in each benchmark function
(BenchmarkPacket_MarshalVT, BenchmarkPacket_UnmarshalVT,
BenchmarkRelayInfo_MarshalVT, BenchmarkLease_MarshalVT); keep the existing setup
(message creation, b.ResetTimer(), MarshalVT/UnmarshalVT calls) but change the
loop header so the body executes under `for b.Loop() { ... }` to comply with the
benchmarking guideline.

In `@portal/utils/wsstream/wsstream_test.go`:
- Around line 422-437: BenchmarkWsStream_Read is measuring the EOF error path
because newMockConn has only one message and readIndex is only reset every 1000
iterations; change the benchmark to use the b.Loop() pattern and ensure each
iteration reads valid data (e.g., reset mock.readIndex at the start of each loop
or create a fresh mock via newMockConn per loop) so WsStream.Read measures
successful reads rather than repeated io.EOF errors; update
BenchmarkWsStream_Read to use b.Loop() and either reset mock.readIndex or
recreate mock/stream inside the loop.

In `@portal/utils/wsstream/wsstream.go`:
- Around line 57-62: The Read wrapper currently discards bytes when
g.currentReader.Read(p) returns n>0 with err==io.EOF; change the logic in the
Read method handling of g.currentReader so that when err==io.EOF and n>0 you set
g.currentReader = nil and return n, nil (so the caller receives the final
bytes), and only when n==0 return 0, io.EOF; keep references to g.currentReader
and the Read loop logic to locate where to apply this change.

In `@utils/utils_test.go`:
- Around line 490-547: TestUpgradeWebSocket currently uses httptest.NewRecorder
which does not implement http.Hijacker so UpgradeWebSocket can never succeed;
either change the test to assert error paths only (rename the test to reflect it
validates error handling and remove the misleading success branch around conn)
or replace the recorder with a real HTTP server using httptest.NewServer that
performs an actual HTTP request to a handler which calls UpgradeWebSocket so the
ResponseWriter supports Hijacker and a real websocket upgrade/handshake can be
exercised; update assertions in TestUpgradeWebSocket accordingly and keep
references to the UpgradeWebSocket call and the test name to locate the change.
🧹 Nitpick comments (13)
portal/core/proto/rdverb/rdverb_test.go (1)

882-911: TestEmptyResponseMessages duplicates TestResponseCode_AllValues.

Both test the exact same thing: round-trip marshal/unmarshal of LeaseUpdateResponse for every ResponseCode value. One of them should be removed.

portal/core/proto/rdsec/rdsec_test.go (1)

478-512: Concurrent test uses goroutines that call t.Errorf via closure over outer t.

This works because the done channel ensures all goroutines finish before the test returns, and t.Errorf (unlike t.FailNow) is safe to call from non-test goroutines. That said, consider using errgroup or at minimum passing t explicitly for clarity. Not blocking.

justfile (3)

1-3: fmt target is missing goimports.

The coding guidelines require gofmt + goimports for formatting. This target only runs gofmt -w .. Add goimports -w . (or use the golangci-lint goimports fixer) to stay compliant.

Also, > /dev/null || true swallows all golangci-lint output and errors silently — consider at least redirecting to stderr or logging a note so developers aren't confused when fixes are applied but diagnostics vanish.

Proposed fix
 fmt:
-    golangci-lint run --fast-only --allow-parallel-runners --fix > /dev/null || true
-    gofmt -w .
+    golangci-lint run --fast-only --allow-parallel-runners --fix 2>/dev/null || true
+    goimports -w .
+    gofmt -w .

As per coding guidelines: "Run formatting before commits using make fmt (gofmt + goimports)"


5-6: lint target disables errcheck — intentional?

The coding guidelines state "Never ignore errors unless explicitly excluded by errcheck." Disabling the errcheck linter entirely (-D errcheck) means no error-ignoring violations will be caught during just lint. If this is a temporary measure, add a comment explaining why.


11-12: tidy target is missing go mod verify.

The project guidelines specify go mod tidy + go mod verify for module management.

Proposed fix
 tidy:
     go mod tidy
+    go mod verify

As per coding guidelines (learning): "Run make tidy for Go module management (go mod tidy + go mod verify)"

portal/utils/ratelimit/bucket_test.go (1)

304-309: Copy return error is silently dropped in concurrent test.

Not a big deal in a test, but if Copy fails for a reason other than rate limiting, this test won't catch it.

Optional: capture and check errors
+	errs := make([]error, numGoroutines)
+	var mu sync.Mutex
+	i := 0
 	for range numGoroutines {
 		go func() {
 			defer wg.Done()
 			src := bytes.NewReader(data)
 			var dst bytes.Buffer
-			Copy(&dst, src, b)
+			_, err := Copy(&dst, src, b)
+			mu.Lock()
+			errs[i] = err
+			i++
+			mu.Unlock()
 		}()
 	}
 	wg.Wait()
+	for _, err := range errs {
+		if err != nil {
+			t.Errorf("unexpected Copy error: %v", err)
+		}
+	}
.pre-commit-config.yaml (2)

47-55: Pre-commit hook only runs gofmt, missing goimports.

The make fmt target runs both gofmt and goimports, but this hook only runs gofmt -w .. Import ordering won't be enforced at commit time.

Proposed fix
   - repo: local
     hooks:
       - id: gofmt
         name: gofmt
         description: Formats Go source code.
-        entry: gofmt -w .
+        entry: bash -c 'gofmt -w . && goimports -w .'
         types: [go]
         language: system
         pass_filenames: false

Alternatively, just call make fmt as the entry.

As per coding guidelines: "Run formatting before commits using make fmt (gofmt + goimports)".


24-39: Commented-out hooks are dead weight.

These 16 lines of commented-out configuration add no value. Either remove them or add a note explaining why they're preserved for reference. Leaving them in just adds noise.

utils/utils_test.go (2)

477-488: Use t.Context() instead of context.Background().

Go 1.24+ provides t.Context() which is automatically cancelled when the test ends.

 func TestNewWebSocketDialer(t *testing.T) {
-	ctx := context.Background()
+	ctx := t.Context()
 	dialer := NewWebSocketDialer()

This would also let you drop the "context" import if unused elsewhere.

As per coding guidelines: "Use t.Context() in tests where applicable".


323-339: Overly complex assertion logic obscures test intent.

This 6-condition boolean expression is hard to reason about. In practice, only the "localhost:8080" case hits the "best-effort" path, and it still goes through the else branch because it contains :. Consider splitting hostname-resolution-dependent cases into a separate subtest guarded by a skip condition, rather than embedding this logic in every iteration.

portal/utils/wsstream/wsstream.go (1)

46-52: Redundant nil check on inner if.

Line 49's if err != nil && is always true here since the outer if err != nil on line 47 already guards this block.

-			if err != nil && strings.HasPrefix(err.Error(), "websocket: close ") {
+			if strings.HasPrefix(err.Error(), "websocket: close ") {
portal/utils/wsstream/wsstream_test.go (1)

439-450: Use for b.Loop() {} in Write benchmark.

-	for range b.N {
+	for b.Loop() {
 		stream.Write(data)
 	}

Note: this benchmark also accumulates unbounded data in mock.writeData. For large b.N, memory use will balloon. Consider resetting or using a mock that discards writes.

As per coding guidelines: "Use for b.Loop() {} pattern in benchmarks".

cmd/webclient/main_js_prod.go (1)

370-376: WriteMessage error in Close() is silently ignored.

If the connection is already dead, the close-message write will fail. That's fine for the happy path, but logging or at least acknowledging the error would help with debugging. Minor since closeOnce protects idempotency and conn.Close() follows regardless.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9dc1c88 and f93cd82.

⛔ Files ignored due to path filters (5)
  • go.sum is excluded by !**/*.sum
  • portal/core/proto/rdsec/rdsec.pb.go is excluded by !**/*.pb.go
  • portal/core/proto/rdsec/rdsec_vtproto.pb.go is excluded by !**/*.pb.go
  • portal/core/proto/rdverb/rdverb.pb.go is excluded by !**/*.pb.go
  • portal/core/proto/rdverb/rdverb_vtproto.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (19)
  • .pre-commit-config.yaml
  • Makefile
  • cmd/portal-tunnel/main.go
  • cmd/relay-server/manager/bps_manager.go
  • cmd/webclient/inject.go
  • cmd/webclient/main_js.go
  • cmd/webclient/main_js_prod.go
  • go.mod
  • justfile
  • portal/core/cryptoops/handshaker.go
  • portal/core/proto/rdsec/rdsec_test.go
  • portal/core/proto/rdverb/rdverb_test.go
  • portal/utils/ratelimit/bucket.go
  • portal/utils/ratelimit/bucket_test.go
  • portal/utils/wsstream/wsstream.go
  • portal/utils/wsstream/wsstream_test.go
  • sdk/sdk.go
  • utils/utils_test.go
  • utils/ws.go
🧰 Additional context used
📓 Path-based instructions (3)
go.mod

📄 CodeRabbit inference engine (AGENTS.md)

Pin Go toolchain version to match go.mod (currently 1.25.3)

Files:

  • go.mod
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Run formatting before commits using make fmt (gofmt + goimports)
Import order must follow: stdlib -> external -> internal (blank-line separated)
Use lowercase single-word package names
Name interfaces as behavior verbs in Go
Use Err prefix for sentinel errors and Error suffix for error types in Go
Use context as first parameter for public I/O functions: func Do(ctx context.Context, ...)
Wrap errors with %w and include call-site context
Use sentinel errors per package and use errors.Is/errors.As for error checking
Use errors.Join for multi-error scenarios in Go
Never ignore errors unless explicitly excluded by errcheck
Use iterator signatures func(yield func() bool), func(yield func(V) bool), or func(yield func(K, V) bool) for Go 1.23+ iterators
Always check yield return in iterators and prefer stdlib helpers like slices.Collect and maps.Keys
Prefer errgroup.Group for parallel work and use SetLimit for bounds
No goroutines without clear exit; creator owns lifecycle
Use directional channels in function signatures; only sender closes channels
Avoid time.After in loops; use context.WithTimeout or time.Ticker instead
Avoid math/rand for security-sensitive operations in Go
Avoid reflect on hot paths; prefer generics or type switches
Use sync.Pool only on hot paths for performance optimization

Files:

  • sdk/sdk.go
  • portal/utils/ratelimit/bucket.go
  • utils/utils_test.go
  • portal/utils/wsstream/wsstream_test.go
  • portal/core/proto/rdverb/rdverb_test.go
  • cmd/portal-tunnel/main.go
  • cmd/relay-server/manager/bps_manager.go
  • utils/ws.go
  • portal/core/proto/rdsec/rdsec_test.go
  • portal/utils/wsstream/wsstream.go
  • cmd/webclient/inject.go
  • portal/utils/ratelimit/bucket_test.go
  • cmd/webclient/main_js.go
  • portal/core/cryptoops/handshaker.go
  • cmd/webclient/main_js_prod.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Use race detector in normal test runs with go test -race
Use t.Context() in tests where applicable
Use for b.Loop() {} pattern in benchmarks

Files:

  • utils/utils_test.go
  • portal/utils/wsstream/wsstream_test.go
  • portal/core/proto/rdverb/rdverb_test.go
  • portal/core/proto/rdsec/rdsec_test.go
  • portal/utils/ratelimit/bucket_test.go
🧠 Learnings (20)
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to go.mod : Pin Go toolchain version to match `go.mod` (currently 1.25.3)

Applied to files:

  • go.mod
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Import order must follow: stdlib -> external -> internal (blank-line separated)

Applied to files:

  • go.mod
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Avoid `math/rand` for security-sensitive operations in Go

Applied to files:

  • go.mod
  • cmd/webclient/inject.go
  • cmd/webclient/main_js.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Prefer `errgroup.Group` for parallel work and use `SetLimit` for bounds

Applied to files:

  • go.mod
  • portal/utils/ratelimit/bucket_test.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Use `sync.Pool` only on hot paths for performance optimization

Applied to files:

  • portal/utils/ratelimit/bucket.go
  • cmd/portal-tunnel/main.go
  • cmd/relay-server/manager/bps_manager.go
  • portal/core/cryptoops/handshaker.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*_test.go : Use `t.Context()` in tests where applicable

Applied to files:

  • utils/utils_test.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Run `make build` to build all artifacts, or use specific targets: `make build-server`, `make build-frontend`, `make build-wasm`, `make build-tunnel`, `make build-protoc`

Applied to files:

  • Makefile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: For frontend development in `cmd/relay-server/frontend/`, use `npm run dev` for development, `npm run build` for production builds, and `npm run lint` for linting

Applied to files:

  • Makefile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Run formatting before commits using `make fmt` (gofmt + goimports)

Applied to files:

  • .pre-commit-config.yaml
  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to .golangci.yml : Use linter tiers configuration in `.golangci.yml` for correctness, quality, concurrency safety, and performance/modernization checks

Applied to files:

  • .pre-commit-config.yaml
  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Run `make lint` for golangci-lint analysis

Applied to files:

  • .pre-commit-config.yaml
  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Avoid `reflect` on hot paths; prefer generics or type switches

Applied to files:

  • cmd/webclient/inject.go
  • cmd/webclient/main_js.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*_test.go : Use `for b.Loop() {}` pattern in benchmarks

Applied to files:

  • cmd/webclient/inject.go
  • cmd/webclient/main_js.go
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Use `go vet`, `golangci-lint`, `go test -race`, and `govulncheck` for static analysis

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Run `make vet` for Go vet analysis

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Run `make tidy` for Go module management (`go mod tidy` + `go mod verify`)

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Run `make test` for Go tests with `-v -race` flags

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : Never ignore errors unless explicitly excluded by errcheck

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*_test.go : Use race detector in normal test runs with `go test -race`

Applied to files:

  • justfile
📚 Learning: 2026-01-27T12:45:06.366Z
Learnt from: CR
Repo: gosuda/portal PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-27T12:45:06.366Z
Learning: Applies to **/*.go : No goroutines without clear exit; creator owns lifecycle

Applied to files:

  • cmd/webclient/main_js_prod.go
🧬 Code graph analysis (8)
utils/utils_test.go (3)
utils/http.go (3)
  • IsHTMLContentType (12-21)
  • SetCORSHeaders (50-54)
  • IsLocalhost (56-81)
utils/url.go (5)
  • IsHexString (93-100)
  • StripWildCard (138-142)
  • DefaultAppPattern (170-184)
  • DefaultBootstrapFrom (193-206)
  • StripPort (144-162)
utils/ws.go (3)
  • NewWebSocketDialer (15-27)
  • UpgradeWebSocket (35-37)
  • UpgradeToWSStream (40-46)
portal/utils/wsstream/wsstream_test.go (1)
portal/utils/wsstream/wsstream.go (2)
  • WsStream (20-25)
  • New (28-32)
utils/ws.go (1)
portal/utils/wsstream/wsstream.go (1)
  • New (28-32)
portal/core/proto/rdsec/rdsec_test.go (1)
portal/core/proto/rdsec/rdsec.pb.go (6)
  • Identity (40-44)
  • ClientInitPayload (66-74)
  • ProtocolVersion_PROTOCOL_VERSION_1 (13-13)
  • SignedPayload (124-128)
  • ServerInitPayload (150-158)
  • ProtocolVersion (10-10)
portal/utils/wsstream/wsstream.go (1)
cmd/webclient/wsjs/ws_js.go (1)
  • Conn (19-26)
portal/utils/ratelimit/bucket_test.go (1)
portal/utils/ratelimit/bucket.go (3)
  • NewBucket (21-37)
  • Bucket (12-17)
  • Copy (70-101)
portal/core/cryptoops/handshaker.go (1)
portal/core/proto/rdsec/rdsec.pb.go (2)
  • ServerInitPayload (150-158)
  • ClientInitPayload (66-74)
cmd/webclient/main_js_prod.go (3)
cmd/webclient/main_js.go (7)
  • WebSocketManager (197-199)
  • StreamMessage (235-241)
  • NewWebSocketManager (243-245)
  • WSConnection (201-211)
  • ConnectRequest (218-221)
  • ConnectResponse (223-226)
  • SendRequest (228-233)
utils/http.go (1)
  • IsHTMLContentType (12-21)
cmd/webclient/sdk_js.go (1)
  • WebSocketDialerJS (11-22)
🔍 Remote MCP Github Grep

Summary of Relevant Context for PR Review

Based on searching for concrete patterns and best practices used in production systems, here are the key findings that validate the PR's technical approach:

1. sync.Pool Pointer Pattern (Buffer Pool Optimizations)

The PR's change to store *[]byte pointers in sync.Pool instead of plain []byte values is a well-established optimization pattern used across production systems like containerd, ethereum-optimism, and gvisor. The standard retrieval pattern—bufPool.Get().(*[]byte), dereference to []byte, then defer bufPool.Put(p)—is confirmed in multiple high-profile projects. This reduces interface boxing allocations and is considered a standard best practice for performance-critical buffer management.

2. VTProto Migration (Protobuf Serialization)

The migration from reflection-based protobuf to vtproto (MarshalVT/UnmarshalVT) is designed for reflection-free, TinyGo-compatible implementations that are more efficient at runtime and result in smaller code binaries, with better support for TinyGo's limited reflection capabilities. [::web_search::] This approach eliminates reflection usage and generates optimized code for serialization/deserialization for each specific message, a pattern used by major systems like Vitess and etcd.

3. go:build !prod Build Constraint Pattern

The pattern of using //go:build !prod to exclude development-only code (injection, debugging) from production builds is a standard approach used in enterprise projects (confirmed in Mattermost, Keybase). This ensures production binaries remain lean and deterministic.

4. HTTP Response Body Resource Management

The addition of resp.Body.Close() in error paths follows Go's standard resource management pattern confirmed across numerous production projects (Vault, Harbor, GitHub repositories), preventing connection leaks.

5. WASM Optimization Strategy (Dual-Pass)

The PR's dual-pass wasm-opt approach (O4 for runtime performance, then Oz for aggressive size reduction) aligns with wasm-opt's optimization levels: -O4 applies aggressive transformations to flatten the IR and optimize complex code, while -Oz focuses on super-aggressive code size reduction. [::web_search::] This production-level optimization strategy, though requiring longer compilation time due to depth of transformations, results in highly efficient code that executes faster and is usually smaller in size.

6. New WebSocket Interface Pattern

The refactoring of WsStream to use a mockable webSocketConn interface follows Go's dependency injection pattern, enabling easier testing while preserving production use of gorilla/websocket. This is validated as a standard practice for improving testability without performance overhead.

🔇 Additional comments (25)
sdk/sdk.go (2)

107-112: LGTM — clean pre-allocation under the lock.

Pre-sizing the slice to len(g.relays) avoids needless grow-and-copy cycles during the append loop. Consistent with the same pattern in Close(). No issues.


629-634: LGTM — same pre-allocation pattern applied consistently.

Matches the Dial change. Lock scope is tight, slice is iterated outside the critical section. No issues.

portal/core/cryptoops/handshaker.go (2)

387-387: VT migration in ServerHandshake is equally clean.

Same mechanical swap on the server side — UnmarshalVT for incoming SignedPayload/ClientInitPayload, MarshalVT for outgoing ServerInitPayload/SignedPayload. Consistent with the client path. No concerns.

Also applies to: 393-393, 430-430, 454-454


318-318: VT migration in ClientHandshake is correct.

All MarshalVT/UnmarshalVT call sites are verified. The three proto types (ClientInitPayload, SignedPayload, ServerInitPayload) all have the required methods generated in rdsec_vtproto.pb.go. The 1:1 swap from reflection-based proto.Marshal/proto.Unmarshal is complete across all eight changed lines, with error handling preserved.

portal/core/proto/rdverb/rdverb_test.go (3)

3-8: Import order and structure look correct.

stdlib (bytes, testing) followed by external (rdsec) with blank-line separation. No issues here.


504-576: Solid deep-clone independence tests.

Good coverage verifying that modifying cloned Packet, RelayInfo, and Lease fields (including nested identity and slice elements) doesn't affect the original. This is exactly the kind of test that catches shallow-copy bugs.


762-833: Thorough nil-handling coverage across all message types.

Every public message type is checked for nil CloneVT, nil MarshalVT, and nil SizeVT. Nothing missing.

portal/core/proto/rdsec/rdsec_test.go (2)

1-6: Clean imports, correct package name.

stdlib-only imports, properly organized.


8-64: Good round-trip coverage for Identity.

Covers empty, full, partial-field cases. The wantErr pattern is consistent and the equality check via EqualVT is appropriate.

cmd/webclient/main_js.go (1)

1-2: Build tag looks correct.

The //go:build !prod constraint properly excludes this file from production builds, working in tandem with main_js_prod.go. No issues here.

portal/utils/ratelimit/bucket.go (1)

62-75: sync.Pool pointer pattern is correctly applied.

The *[]byte pool pattern avoids interface boxing allocations — standard optimization. Retrieval, dereference, and deferred return are all correct.

go.mod (1)

12-15: Dependency bumps look routine.

Minor version bumps for x/crypto, x/net, protobuf, and broccoli. No concerns.

cmd/relay-server/manager/bps_manager.go (1)

334-348: Same *[]byte pool optimization — correctly applied, consistent with ratelimit/bucket.go.

Pattern mirrors the identical change in portal/utils/ratelimit/bucket.go. No issues.

Side note: this file duplicates Bucket, NewBucket, Take, Copy, and bufPool from portal/utils/ratelimit/bucket.go (with a different Bucket algorithm). If these were consolidated, you'd only need to apply this kind of change once. Not blocking — just a maintenance observation.

utils/ws.go (2)

17-25: Resource leak fix — good catch closing resp.Body on error.

The gorilla/websocket Dial can return a non-nil response even on error. Closing resp.Body in the error path prevents connection leaks. The factory switch to wsstream.New(wsConn) is clean.


45-45: Consistent factory usage.

wsstream.New(wsConn) aligns with the refactored constructor in wsstream.go.

cmd/portal-tunnel/main.go (2)

22-28: Consistent *[]byte pool pattern across the codebase.

Same correct optimization applied here. Local buf per goroutine — no aliasing risk.


206-218: Buffer retrieval in goroutines is safe.

Each goroutine gets its own local buf from the pool and defers the return. The io.CopyBuffer call uses the dereferenced slice correctly. No concerns.

portal/utils/ratelimit/bucket_test.go (3)

40-60: Good coverage of invalid rate edge cases.

Table-driven tests for zero and negative rates — clean.


399-416: shortWriter returns both bytes written and an error simultaneously.

This works for this test because Copy checks ew != nil before nr != nw, so the write error path is hit. But note: if the Copy implementation ever reorders those checks, the assertion on io.ErrShortWrite (line 279) could break. The test is correct as-is given the current Copy implementation — just be aware of the coupling.


418-423: errWriter.Write returns len(p) alongside a non-nil error.

This is a valid (if unusual) io.Writer behavior. The Copy code path returns the write error after counting the bytes — which is exactly what TestCopyWriteError asserts (lines 329-337). Correct.

cmd/webclient/inject.go (1)

1-2: Build constraint looks correct.

Clean separation of dev-only code via //go:build !prod. Pairs properly with the prod-tagged main_js_prod.go.

portal/utils/wsstream/wsstream.go (1)

11-17: Interface design is clean and minimal.

Good abstraction — three methods, unexported, mockable. Covers exactly the surface area WsStream needs.

portal/utils/wsstream/wsstream_test.go (1)

70-232: Solid test coverage for Read.

Good variety: single message, partial reads, multi-message, empty buffer, EOF, errors, close errors, and concurrency. Well structured.

cmd/webclient/main_js_prod.go (1)

811-866: Main function setup and blocking pattern look correct.

Bootstrap server discovery, SDK client init with defer close, proxy wiring, JS interop hooks, and the TinyGo guard + blocking channel are all properly structured.

Makefile (1)

62-67: The -gcflags="-l=4" syntax is valid and correct.

The -l=4 flag enables aggressive mid-stack inlining in Go's compiler—it's the proper numeric form to relax inlining costs for non-leaf functions. Your Makefile is correct here, not a concern.

The -trimpath inside -gcflags is technically redundant with the top-level -trimpath flag (which already sets it for both gcflags and asmflags), but it causes no harm.

Likely an incorrect or invalid review comment.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +237 to +240
conn, resp, err := dialer.Dial(u.String(), nil)
if err != nil {
return nil, "", err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resource leak: resp.Body not closed on dial error.

When dialer.Dial fails, the HTTP response may still have a non-nil body that needs closing — exactly the pattern already fixed in utils/ws.go's NewWebSocketDialer.

Proposed fix
 	conn, resp, err := dialer.Dial(u.String(), nil)
 	if err != nil {
+		if resp != nil {
+			resp.Body.Close()
+		}
 		return nil, "", err
 	}
📝 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
conn, resp, err := dialer.Dial(u.String(), nil)
if err != nil {
return nil, "", err
}
conn, resp, err := dialer.Dial(u.String(), nil)
if err != nil {
if resp != nil {
resp.Body.Close()
}
return nil, "", err
}
🤖 Prompt for AI Agents
In `@cmd/webclient/main_js_prod.go` around lines 237 - 240, The dialer.Dial call
in cmd/webclient/main_js_prod.go can return a non-nil resp with a Body even when
err != nil; modify the error path after dialer.Dial(u.String(), nil) to close
resp.Body if resp != nil before returning the error (mirror the pattern used in
utils/ws.go's NewWebSocketDialer). Ensure you still return the original error
and do not swallow it; reference the variables dialer.Dial, resp, conn, and err
when making the change.

Comment on lines +406 to +412
}

r = r.Clone(context.Background())

decodedHost := getLeaseID(r.URL.Hostname())
r.URL.Host = decodedHost
r.URL.Scheme = "http"
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

context.Background() drops the original request's cancellation signal.

r.Clone(context.Background()) severs the connection to the client's context. If the browser disconnects, the proxied request will continue until httpClient.Timeout (30s) expires instead of cancelling promptly. Use the incoming request's context or derive from it.

-	r = r.Clone(context.Background())
+	r = r.Clone(r.Context())

Or simply don't clone the context at all if you only need to modify URL fields.

🤖 Prompt for AI Agents
In `@cmd/webclient/main_js_prod.go` around lines 406 - 412, The code uses
r.Clone(context.Background()) which drops the incoming request's cancellation;
instead preserve the client's context by cloning with r.Context() (e.g., r =
r.Clone(r.Context())) or simply avoid cloning and modify r.URL directly after
reading the request context; ensure the proxied request uses the original
context so cancellation from the browser propagates when you call getLeaseID and
then set r.URL.Host and r.URL.Scheme.

Comment on lines +414 to +417
resp, err := httpClient.Do(r)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to proxy request to %s", r.URL.String()), http.StatusBadGateway)
return
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Internal resolved URL leaked in error response.

r.URL.String() at this point contains the resolved lease ID (from getLeaseID), not the original hostname. This exposes internal routing details to the client.

🤖 Prompt for AI Agents
In `@cmd/webclient/main_js_prod.go` around lines 414 - 417, The error response
leaks an internal resolved URL via r.URL.String() after calling
httpClient.Do(r); remove the internal URL from the client-facing message and
return a generic error (e.g., "Failed to proxy request") or use the original
public host (r.Host or r.Header.Get("Host")) if you must include a hostname, and
separately log the detailed error and r.URL.String()/err server-side for
debugging; update the http.Error call that currently uses fmt.Sprintf("Failed to
proxy request to %s", r.URL.String()) and add a process/server log of err and
r.URL.String() instead.

Comment on lines +425 to +432
if utils.IsHTMLContentType(resp.Header.Get("Content-Type")) {
w.WriteHeader(resp.StatusCode)
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
w.Write(body)
return
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unbounded io.ReadAll on proxied HTML responses.

A malicious or misbehaving upstream could return an arbitrarily large HTML body, exhausting WASM memory. Consider using io.LimitReader with a reasonable cap.

🤖 Prompt for AI Agents
In `@cmd/webclient/main_js_prod.go` around lines 425 - 432, The code uses
io.ReadAll on proxied HTML responses (see utils.IsHTMLContentType, resp.Body and
io.ReadAll), which can exhaust WASM memory; fix by replacing
io.ReadAll(resp.Body) with reading from io.LimitReader(resp.Body,
maxHTMLBodyBytes) where maxHTMLBodyBytes is a reasonable constant (e.g. a few
MB), then handle the case where the response is truncated (log or return a
502/413) before calling w.WriteHeader(resp.StatusCode) and w.Write; ensure
resp.Body is still closed and errors from reading the limited reader are handled
and surfaced to the client.

Comment on lines +603 to +607
func handleSDKConnect(data js.Value) {
defer func() {
if r := recover(); r != nil {
}
}()
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Empty recover() blocks silently swallow panics.

Four locations have defer func() { if r := recover(); r != nil {} }() that catch and discard all panics without logging, metrics, or any signal. This makes debugging production failures extremely difficult. At minimum, log the recovered value.

Also applies to: 627-630, 722-726, 777-781

🤖 Prompt for AI Agents
In `@cmd/webclient/main_js_prod.go` around lines 603 - 607, The empty
defer/recover closures in cmd/webclient/main_js_prod.go (including the one in
handleSDKConnect and the three other similar closures) silently swallow panics;
change each defer func() { if r := recover(); r != nil {} }() to capture the
recovered value and at minimum log it (e.g., console.Error or the package
logger) with contextual information and a stack trace (use
runtime/debug.Stack()) so errors are visible in production; keep the recover but
replace the empty body with a log statement that includes the recovered value
and stack trace, or rethrow/propagate after logging if the caller must handle
it.

Comment on lines +894 to +925
// TestServerInitPayload_Reset tests Reset method
func TestServerInitPayload_Reset(t *testing.T) {
msg := &ServerInitPayload{
Version: ProtocolVersion_PROTOCOL_VERSION_1,
Nonce: []byte{0x01},
Timestamp: 123,
Identity: &Identity{Id: "test"},
Alpn: "h2",
SessionPublicKey: []byte{0xAA},
}

msg.Reset()

if msg.Version != ProtocolVersion_PROTOCOL_VERSION_1 {
t.Error("Reset() changed Version from default")
}
if msg.Nonce != nil {
t.Error("Reset() did not clear Nonce")
}
if msg.Timestamp != 0 {
t.Error("Reset() did not clear Timestamp")
}
if msg.Identity != nil {
t.Error("Reset() did not clear Identity")
}
if msg.Alpn != "" {
t.Error("Reset() did not clear Alpn")
}
if msg.SessionPublicKey != nil {
t.Error("Reset() did not clear SessionPublicKey")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

TestServerInitPayload_Reset assertion for Version is subtly misleading.

Line 907 asserts msg.Version != ProtocolVersion_PROTOCOL_VERSION_1 after Reset(). Since PROTOCOL_VERSION_1 is 0 (the zero value), this doesn't actually verify that Reset() preserved the version — it verifies it was zeroed out, which coincidentally equals PROTOCOL_VERSION_1. The error message "Reset() changed Version from default" suggests the opposite intent. If the enum ever gains a non-zero default, this test silently passes incorrectly.

Consider explicitly checking against 0:

-	if msg.Version != ProtocolVersion_PROTOCOL_VERSION_1 {
-		t.Error("Reset() changed Version from default")
+	if msg.Version != 0 {
+		t.Error("Reset() did not clear Version")
 	}
🤖 Prompt for AI Agents
In `@portal/core/proto/rdsec/rdsec_test.go` around lines 894 - 925, The test
TestServerInitPayload_Reset currently asserts msg.Version !=
ProtocolVersion_PROTOCOL_VERSION_1 which is misleading because
ProtocolVersion_PROTOCOL_VERSION_1 is the zero value; change the assertion to
explicitly check msg.Version == 0 (or != 0 if you intend a non-zero default) and
update the error message accordingly so it clearly asserts that Reset zeroes the
Version (referencing msg.Version and ProtocolVersion_PROTOCOL_VERSION_1 in the
TestServerInitPayload_Reset function).

Comment on lines +974 to +1050
// BenchmarkPacket_MarshalVT benchmarks packet marshaling
func BenchmarkPacket_MarshalVT(b *testing.B) {
msg := &Packet{
Type: PacketType_PACKET_TYPE_CONNECTION_REQUEST,
Payload: bytes.Repeat([]byte{0x01}, 1024),
}

b.ResetTimer()
for range b.N {
_, _ = msg.MarshalVT()
}
}

// BenchmarkPacket_UnmarshalVT benchmarks packet unmarshaling
func BenchmarkPacket_UnmarshalVT(b *testing.B) {
msg := &Packet{
Type: PacketType_PACKET_TYPE_CONNECTION_REQUEST,
Payload: bytes.Repeat([]byte{0x01}, 1024),
}

data, _ := msg.MarshalVT()

b.ResetTimer()
for range b.N {
got := &Packet{}
_ = got.UnmarshalVT(data)
}
}

// BenchmarkRelayInfo_MarshalVT benchmarks complex relay info marshaling
func BenchmarkRelayInfo_MarshalVT(b *testing.B) {
msg := &RelayInfo{
Identity: &rdsec.Identity{
Id: "benchmark-relay",
PublicKey: bytes.Repeat([]byte{0xAA}, 32),
},
Address: []string{"addr1:8080", "addr2:8080", "addr3:8080"},
Leases: []*Lease{
{
Identity: &rdsec.Identity{Id: "l1", PublicKey: bytes.Repeat([]byte{0x01}, 32)},
Expires: 1234567890,
Name: "lease1",
Alpn: []string{"h2", "grpc"},
},
{
Identity: &rdsec.Identity{Id: "l2", PublicKey: bytes.Repeat([]byte{0x02}, 32)},
Expires: 9876543210,
Name: "lease2",
Alpn: []string{"h2"},
},
},
}

b.ResetTimer()
for range b.N {
_, _ = msg.MarshalVT()
}
}

// BenchmarkLease_MarshalVT benchmarks lease marshaling
func BenchmarkLease_MarshalVT(b *testing.B) {
msg := &Lease{
Identity: &rdsec.Identity{
Id: "benchmark-lease",
PublicKey: bytes.Repeat([]byte{0xBB}, 32),
},
Expires: 1234567890,
Name: "benchmark-lease",
Alpn: []string{"h2", "grpc", "http/1.1"},
Metadata: "benchmark metadata",
}

b.ResetTimer()
for range b.N {
_, _ = msg.MarshalVT()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Benchmarks must use for b.Loop() {} instead of for range b.N.

All four benchmarks (BenchmarkPacket_MarshalVT, BenchmarkPacket_UnmarshalVT, BenchmarkRelayInfo_MarshalVT, BenchmarkLease_MarshalVT) use the old for range b.N loop pattern. The coding guidelines require for b.Loop() {}.

Example fix for BenchmarkPacket_MarshalVT (apply same pattern to all four)
 func BenchmarkPacket_MarshalVT(b *testing.B) {
 	msg := &Packet{
 		Type:    PacketType_PACKET_TYPE_CONNECTION_REQUEST,
 		Payload: bytes.Repeat([]byte{0x01}, 1024),
 	}

 	b.ResetTimer()
-	for range b.N {
+	for b.Loop() {
 		_, _ = msg.MarshalVT()
 	}
 }

As per coding guidelines, "Use for b.Loop() {} pattern in benchmarks".

🤖 Prompt for AI Agents
In `@portal/core/proto/rdverb/rdverb_test.go` around lines 974 - 1050, Replace the
old benchmark loop pattern `for range b.N { ... }` with the new `for b.Loop() {
... }` in each benchmark function (BenchmarkPacket_MarshalVT,
BenchmarkPacket_UnmarshalVT, BenchmarkRelayInfo_MarshalVT,
BenchmarkLease_MarshalVT); keep the existing setup (message creation,
b.ResetTimer(), MarshalVT/UnmarshalVT calls) but change the loop header so the
body executes under `for b.Loop() { ... }` to comply with the benchmarking
guideline.

Comment on lines +422 to +437
func BenchmarkWsStream_Read(b *testing.B) {
data := make([]byte, 1024)
mock := newMockConn(data)
stream := &WsStream{Conn: mock}

buf := make([]byte, 1024)

b.ResetTimer()
for i := range b.N {
stream.Read(buf)
// Reset for next iteration
if i%1000 == 999 {
mock.readIndex = 0
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Read benchmark is broken — measures the EOF error path ~999/1000 iterations.

The mock has one message, so after the first Read succeeds, all subsequent calls hit NextReaderio.EOF until readIndex resets every 1000th iteration. You're benchmarking error returns, not reads.

Also, use for b.Loop() {} per project guidelines instead of for i := range b.N.

Proposed fix
 func BenchmarkWsStream_Read(b *testing.B) {
 	data := make([]byte, 1024)
-	mock := newMockConn(data)
-	stream := &WsStream{Conn: mock}
-
 	buf := make([]byte, 1024)
 
 	b.ResetTimer()
-	for i := range b.N {
-		stream.Read(buf)
-		// Reset for next iteration
-		if i%1000 == 999 {
-			mock.readIndex = 0
-		}
+	for b.Loop() {
+		mock := newMockConn(data)
+		stream := &WsStream{Conn: mock}
+		stream.Read(buf)
 	}
 }

As per coding guidelines: "Use for b.Loop() {} pattern in benchmarks".

🤖 Prompt for AI Agents
In `@portal/utils/wsstream/wsstream_test.go` around lines 422 - 437,
BenchmarkWsStream_Read is measuring the EOF error path because newMockConn has
only one message and readIndex is only reset every 1000 iterations; change the
benchmark to use the b.Loop() pattern and ensure each iteration reads valid data
(e.g., reset mock.readIndex at the start of each loop or create a fresh mock via
newMockConn per loop) so WsStream.Read measures successful reads rather than
repeated io.EOF errors; update BenchmarkWsStream_Read to use b.Loop() and either
reset mock.readIndex or recreate mock/stream inside the loop.

Comment on lines +57 to +62
n, err = g.currentReader.Read(p)
if err == io.EOF {
// Current message exhausted, try to get next one
g.currentReader = nil
continue
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Data loss when Read returns n > 0 alongside io.EOF.

The io.Reader contract permits returning n > 0, io.EOF simultaneously (i.e., the last chunk of data with EOF). This code discards any bytes read when err == io.EOF because it jumps straight to continue without returning n.

This won't surface with bytes.Reader (which returns 0, io.EOF separately), but any reader that signals EOF alongside the final data chunk will silently lose bytes.

Proposed fix
 		n, err = g.currentReader.Read(p)
 		if err == io.EOF {
-			// Current message exhausted, try to get next one
 			g.currentReader = nil
-			continue
+			if n > 0 {
+				return n, nil
+			}
+			continue
 		}
📝 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
n, err = g.currentReader.Read(p)
if err == io.EOF {
// Current message exhausted, try to get next one
g.currentReader = nil
continue
}
n, err = g.currentReader.Read(p)
if err == io.EOF {
g.currentReader = nil
if n > 0 {
return n, nil
}
continue
}
🤖 Prompt for AI Agents
In `@portal/utils/wsstream/wsstream.go` around lines 57 - 62, The Read wrapper
currently discards bytes when g.currentReader.Read(p) returns n>0 with
err==io.EOF; change the logic in the Read method handling of g.currentReader so
that when err==io.EOF and n>0 you set g.currentReader = nil and return n, nil
(so the caller receives the final bytes), and only when n==0 return 0, io.EOF;
keep references to g.currentReader and the Read loop logic to locate where to
apply this change.

Comment on lines +490 to +547
func TestUpgradeWebSocket(t *testing.T) {
tests := []struct {
name string
requestHeaders map[string]string
expectError bool
}{
{
name: "valid websocket upgrade request",
requestHeaders: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
},
expectError: false,
},
{
name: "missing upgrade header",
requestHeaders: map[string]string{
"Connection": "Upgrade",
},
expectError: true,
},
{
name: "no headers",
requestHeaders: map[string]string{},
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
for k, v := range tt.requestHeaders {
req.Header.Set(k, v)
}

w := httptest.NewRecorder()

conn, err := UpgradeWebSocket(w, req, nil)

if tt.expectError {
assert.Error(t, err)
assert.Nil(t, conn)
} else {
// If no error, we should get a connection
// Note: The response might have been written already
if err == nil {
assert.NotNil(t, conn)
conn.Close()
} else {
// Some error cases are acceptable in test environment
assert.NotNil(t, err)
}
}
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

WebSocket upgrade tests will never succeed with httptest.NewRecorder.

httptest.NewRecorder doesn't implement http.Hijacker, so websocket.Upgrade will always fail. The "valid websocket upgrade request" case (line 497) is misleading — it can never reach the success path. The fallback logic on lines 537-543 silently swallows the inevitable error, making this a no-op assertion.

Either use httptest.NewServer with a real HTTP server to test actual upgrades, or rename the test to reflect it only validates error handling.

🤖 Prompt for AI Agents
In `@utils/utils_test.go` around lines 490 - 547, TestUpgradeWebSocket currently
uses httptest.NewRecorder which does not implement http.Hijacker so
UpgradeWebSocket can never succeed; either change the test to assert error paths
only (rename the test to reflect it validates error handling and remove the
misleading success branch around conn) or replace the recorder with a real HTTP
server using httptest.NewServer that performs an actual HTTP request to a
handler which calls UpgradeWebSocket so the ResponseWriter supports Hijacker and
a real websocket upgrade/handshake can be exercised; update assertions in
TestUpgradeWebSocket accordingly and keep references to the UpgradeWebSocket
call and the test name to locate the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants