Skip to content

perf: eliminate redundant syscalls in JSON serialization and filesystem operations - #223

Draft
codspeed-hq[bot] wants to merge 1 commit into
mainfrom
codspeed/optim-eliminate-redundant-syscalls-in-json-serialization-1781211259953
Draft

perf: eliminate redundant syscalls in JSON serialization and filesystem operations#223
codspeed-hq[bot] wants to merge 1 commit into
mainfrom
codspeed/optim-eliminate-redundant-syscalls-in-json-serialization-1781211259953

Conversation

@codspeed-hq

@codspeed-hq codspeed-hq Bot commented Jun 11, 2026

Copy link
Copy Markdown

Fixes ENG-3016

Summary

Two complementary optimizations that reduce redundant work in the hot path of the filesystem API handlers, yielding a measurable improvement on BenchmarkListDirectory.

Changes

1. Replace encoding/json with jsoniter for HTTP serialization (base.go)

Gin's c.JSON() uses the standard library encoding/json internally. By switching to jsoniter.ConfigCompatibleWithStandardLibrary via a new writeJSONResponse() helper, all response serialization bypasses the slower standard library path.

2. Eliminate redundant os.Lstat syscalls in filesystem operations (filesystem.go)

  • ListDirectory: Eliminates redundant os.Lstat() calls by reusing DirEntry.IsDir() and getOwnerAndGroupFromInfo(info).
  • ReadFile / GetFileInfo: Pass existing FileInfo directly, eliminating a redundant syscall per call.

Benchmark Results

Benchmark Baseline Optimized Change
BenchmarkListDirectory 60.3 µs 48.1 µs +25.2%

Note

Performance optimization PR that replaces Gin's c.JSON() with jsoniter-based writeJSONResponse() and eliminates redundant os.Lstat syscalls in filesystem operations by reusing already-obtained FileInfo.

Written by Mendral for commit 36b473f.

@mendral-app

mendral-app Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 Testing Guide

What this PR addresses

This PR optimizes the filesystem API hot path in two ways:

  1. JSON serialization: Replaces Gin's c.JSON() (which uses encoding/json) with a writeJSONResponse() helper using jsoniter, and switches BindJSON from Gin's ShouldBindJSON to a jsoniter decoder.
  2. Redundant syscalls: Eliminates duplicate os.Lstat calls in ListDirectory, ReadFile, and GetFileInfo by reusing already-obtained FileInfo via a new getOwnerAndGroupFromInfo(info) function, and uses DirEntry.IsDir() instead of stat-ing every directory entry.

Steps to reproduce / exercise the changes

Since this is a performance optimization with no intended behavior change, focus on verifying correctness:

  1. List a directoryGET /filesystem/directory?path=/tmp (or any directory with a mix of files, subdirectories, and symlinks). Confirm the response structure and content are unchanged.
  2. Read a fileGET /filesystem/file?path=/etc/hostname. Verify owner and group fields are still populated correctly.
  3. Get file infoGET /filesystem/file/info?path=/etc/hostname. Same check on owner/group metadata.
  4. Symlink handling — Create a symlink pointing to a non-existent target (ln -s /nonexistent /tmp/dangling), then list its parent directory. Confirm no error is returned and the symlink appears as a file entry.
  5. Error responses — Send a malformed JSON body to a write endpoint (e.g., POST /filesystem/file with invalid JSON). Confirm the error response is still well-formed JSON with the correct Content-Type header (application/json; charset=utf-8).
  6. Request parsing — Create/write a file via POST with a valid JSON body. Confirm the operation still succeeds (validates the jsoniter decoder path).

What to verify (expected behavior)

  • No functional regression: All API responses should have identical JSON structure and values as before this PR.
  • Content-Type header: Responses must include Content-Type: application/json; charset=utf-8.
  • Owner/group metadata: File listings and file info responses still correctly report owner and group names (not empty strings or UIDs).
  • Existing tests pass: Run go test ./... in sandbox-api/ — all unit and benchmark tests should pass.
  • Benchmark improvement: If benchmarking, BenchmarkListDirectory should show reduced time (PR claims ~25% improvement on /tmp).
  • Symlinks: Dangling symlinks in directory listings do not cause errors (Lstat behavior preserved for non-directory entries).

Note

Posted by PR Testing Guide · Tag @mendral-app with feedback.

@mendral-app

mendral-app Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🔍 Interaction Flow Diagram

Here's a sequence diagram showing how the optimized components interact after this PR:

sequenceDiagram
    participant Client
    participant Gin as Gin Router
    participant Handler as handler (base.go)
    participant FS as filesystem handler
    participant Jsoniter as jsoniter
    participant OS as OS / Syscalls

    Note over Handler: Response Path (optimized)
    Client->>Gin: HTTP Request
    Gin->>Handler: Route dispatch
    Handler->>Jsoniter: BindJSON (NewDecoder)
    Jsoniter-->>Handler: Decoded request body

    alt ListDirectory / ReadFile / GetFileInfo
        Handler->>FS: Handle filesystem operation
        FS->>OS: os.ReadDir() / os.Lstat() (single call)
        OS-->>FS: DirEntry[] / FileInfo
        FS->>FS: entry.IsDir() (cached, no syscall)
        FS->>FS: getOwnerAndGroupFromInfo(info)
        Note right of FS: Reuses existing FileInfo<br/>instead of redundant os.Lstat()
        FS-->>Handler: Response payload
    end

    Handler->>Handler: writeJSONResponse()
    Handler->>Jsoniter: json.Marshal(payload)
    Jsoniter-->>Handler: []byte
    Handler->>Gin: Write status + JSON body directly
    Gin-->>Client: HTTP Response
Loading

Summary of the Flow

Optimization Before After
JSON serialization c.JSON()encoding/json writeJSONResponse()jsoniter (direct write)
JSON deserialization ShouldBindJSON() → Gin internals json.NewDecoder()jsoniter
ListDirectory os.Lstat() per entry (even dirs) entry.IsDir() from cached DirEntry; os.Lstat() only for files/symlinks
Owner/Group lookup Separate os.Lstat() call in getFileOwnerAndGroup() getOwnerAndGroupFromInfo() reuses already-obtained FileInfo

The key insight is eliminating redundant syscalls by reusing FileInfo already obtained earlier in the call chain, and bypassing Gin's default JSON path for a faster jsoniter implementation.

Note

Posted by PR Sequence Diagram · Tag @mendral-app with feedback.

@mendral-app

mendral-app Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📋 Created Linear issue ENG-3016 — status: In Progress

  • Assignee: (unassigned — bot-authored PR with no human reviewers)
  • Labels: Improvement, Benchmark
  • Estimate: S
  • PR linked: ✅ Issue will auto-close when this PR merges

Auto-created because no Linear reference was found in the PR title, description, or branch name.

Note

Posted by Linear Issue Enforcer · Tag @mendral-app with feedback.

@mendral-app mendral-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Needs attention — 1 issue in 1 file

The filesystem syscall elimination is correct and well-reasoned. The BindJSON change silently drops Gin's struct validation (binding:"required" tags) — multiple request structs like DriveMountRequest, ProcessRequest, and ApplyEditRequest have binding:"required" fields that will no longer be enforced. Even if most callers already used the custom BindJSON, at least drive.go and system.go use c.ShouldBindJSON() directly, confirming that validation was intentionally relied upon somewhere in the codebase.

Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.

<assessment>
The filesystem syscall elimination is correct and well-reasoned. The `BindJSON` change silently drops Gin's struct validation (`binding:"required"` tags) — multiple request structs like `DriveMountRequest`, `ProcessRequest`, and `ApplyEditRequest` have `binding:"required"` fields that will no longer be enforced. Even if most callers already used the custom `BindJSON`, at least `drive.go` and `system.go` use `c.ShouldBindJSON()` directly, confirming that validation was intentionally relied upon somewhere in the codebase.
</assessment>

<file name="sandbox-api/src/handler/base.go">
<issue location="sandbox-api/src/handler/base.go:97">
Switching from `ShouldBindJSON` to raw `json.NewDecoder().Decode()` silently drops Gin's struct validation. Any request struct with `binding:"required"` tags (e.g., `ProcessRequest.Command`, `DriveMountRequest.DriveName`) will no longer have those constraints enforced, allowing invalid requests through.
</issue>
</file>

Tag @mendral-app with feedback or questions. View session

Comment on lines +97 to 99
decoder := json.NewDecoder(c.Request.Body)
if err := decoder.Decode(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bug (P2): Switching from ShouldBindJSON to raw json.NewDecoder().Decode() silently drops Gin's struct validation. Any request struct with binding:"required" tags (e.g., ProcessRequest.Command, DriveMountRequest.DriveName) will no longer have those constraints enforced, allowing invalid requests through.

Suggested change
Suggested change
decoder := json.NewDecoder(c.Request.Body)
if err := decoder.Decode(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)
func (h *BaseHandler) BindJSON(c *gin.Context, obj interface{}) error {
if err := json.NewDecoder(c.Request.Body).Decode(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
if err := binding.Validator.ValidateStruct(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At sandbox-api/src/handler/base.go, line 97:

<issue>
Switching from `ShouldBindJSON` to raw `json.NewDecoder().Decode()` silently drops Gin's struct validation. Any request struct with `binding:"required"` tags (e.g., `ProcessRequest.Command`, `DriveMountRequest.DriveName`) will no longer have those constraints enforced, allowing invalid requests through.
</issue>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Let's break these JSON-related changes into a separate PR. They seem completely unrelated to the Lstat-related changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's a fair point. The JSON serialization changes (switching to jsoniter, adding writeJSONResponse(), updating BindJSON() in base.go) and the Lstat optimizations (eliminating redundant syscalls in filesystem.go) are independent optimizations targeting different layers — HTTP serialization vs. filesystem I/O. They don't share any functional dependency.

Splitting them would make each PR easier to review, benchmark independently, and revert if needed.

// For files and symlinks, we need stat info for metadata
absEntryPath := filepath.Join(absPath, entry.Name())

// Use os.Lstat to get info about the symlink itself, not its target

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-use fs.getFileOwnerAndGroup here

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actually, nevermind, I didn't realize that info is used later on as well.

Comment on lines +97 to 99
decoder := json.NewDecoder(c.Request.Body)
if err := decoder.Decode(obj); err != nil {
return fmt.Errorf("invalid request body: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Let's break these JSON-related changes into a separate PR. They seem completely unrelated to the Lstat-related changes.

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.

1 participant