Skip to content

[go-fan] Go Module Review: wazero (tetratelabs/wazero) #4643

@github-actions

Description

@github-actions

🐹 Go Fan Report: wazero

Module Overview

wazero is a zero-dependency, pure-Go WebAssembly runtime. It supports WASI preview1, JIT compilation, custom host functions, and context-cancellation-aware WASM execution. It's used in this project to run DIFC security guard policies as sandboxed WASM binaries at the MCP gateway boundary.

Version in use: v1.11.0

Current Usage in gh-aw-mcpg

The entire wazero usage lives in internal/guard/wasm.go and its test.

  • Files: 2 (wasm.go, wasm_test.go)
  • Imports: wazero, wazero/api, wazero/imports/wasi_snapshot_preview1, wazero/sys
  • Key APIs Used:
    • wazero.NewRuntimeConfigCompiler() + WithCloseOnContextDone(true) — JIT runtime with context support
    • wazero.NewCompilationCache() — process-level compilation cache shared across all guard instances
    • wasi_snapshot_preview1.Instantiate() — WASI layer for guest modules
    • runtime.NewHostModuleBuilder("env") — custom host module exposing call_backend and host_log to WASM
    • module.ExportedFunction() / fn.Call() — invoking guard functions (label_agent, label_resource, label_response)
    • module.Memory().Read() / .Write() / .Grow() — data exchange across the host/WASM boundary
    • sys.ExitError — distinguishing clean WASI exits from execution traps
    • context.WithoutCancel() — non-cancelable context for dealloc cleanup

The code is generally well-written and uses wazero idiomatically. Notable strengths:

  • Guard traps permanently poison the guard instance (safe failure mode)
  • Adaptive output buffer (4MB → 16MB, 3 retries) handles large responses
  • Preferred allocator path (alloc/dealloc exports) avoids corrupting the WASM heap
  • stdin is explicitly isolated to prevent guards from reading the MCP protocol stream

Research Findings

Recent Updates

wazero v1.11.x continues a mature, stable API. The api.Memory interface has offered typed read/write helpers (ReadUint32Le, WriteUint32Le, etc.) since v1.0. The current usage occasionally manually decodes integers that these helpers could handle directly.

Best Practices (from wazero maintainers)

  • Use ReadUint32Le / WriteUint32Le for integer memory I/O instead of manual bit-shifting
  • Close CompilationCache on process shutdown to release JIT resources
  • Export alloc/dealloc from guards for safe buffer management (project already prefers this path)

Improvement Opportunities

🏃 Quick Wins

1. Use api.Memory.ReadUint32Le() instead of manual bit-shifting

In tryCallWasmFunction, the required buffer size is decoded from WASM memory twice (allocator path and fallback path) using manual bit operations:

// Current — manual LE decode in two places
requiredSize := uint32(sizeBytes[0]) | uint32(sizeBytes[1])<<8 |
    uint32(sizeBytes[2])<<16 | uint32(sizeBytes[3])<<24

wazero's api.Memory already provides a typed helper:

// Cleaner with wazero's built-in helper
requiredSize, ok := mem.ReadUint32Le(outputPtr)
if ok && requiredSize > 0 {
    return nil, requiredSize, nil
}

This eliminates the intermediate sizeBytes slice read and the manual arithmetic, and is self-documenting. Applies to the duplicated pattern at both the allocator and fallback code paths.

2. Route WASM warn/error logs to the operational file logger

hostLog currently dispatches all log levels (debug/info/warn/error) only to the debug logger (logWasm):

case logLevelWarn:
    logWasm.Printf("%sWARN: %s", prefix, msg)
case logLevelError:
    logWasm.Printf("%sERROR: %s", prefix, msg)

WASM guard errors and warnings are important operational signals that should also appear in mcp-gateway.log and gateway.md. The fix is to additionally call the operational logger for warn/error:

case logLevelWarn:
    logWasm.Printf("%sWARN: %s", prefix, msg)
    logger.LogWarn("guard", "[%s] %s", g.name, msg)
case logLevelError:
    logWasm.Printf("%sERROR: %s", prefix, msg)
    logger.LogError("guard", "[%s] %s", g.name, msg)

✨ Feature Opportunities

3. Close the global compilation cache on graceful shutdown

globalCompilationCache is initialized at package startup but never closed in production. Only wasm_test.go's TestMain properly closes it. During server shutdown, Registry.Close() closes individual guard runtimes, but the shared cache is leaked.

A minimal fix is to expose a package-level closer and call it during shutdown:

// In wasm.go
func CloseGlobalCompilationCache(ctx context.Context) error {
    return globalCompilationCache.Close(ctx)
}
// In server/unified.go shutdown path, after guardRegistry.Close():
if err := guard.CloseGlobalCompilationCache(ctx); err != nil {
    logger.LogWarn("shutdown", "Failed to close WASM compilation cache: %v", err)
}

This ensures JIT-compiled code is properly released on graceful shutdown, which matters especially for long-running gateway processes.

4. Consider interpreter fallback for restricted environments

The code unconditionally uses NewRuntimeConfigCompiler() (JIT). In hardened container environments with mmap/mprotect restrictions, JIT initialization may fail at startup. A graceful fallback to NewRuntimeConfigInterpreter() would improve robustness in such deployments. A WasmGuardOptions.UseInterpreter bool field could opt into the interpreter for testing or restricted environments.

📐 Best Practice Alignment

5. Fallback memory layout risks heap corruption

When guards don't export alloc/dealloc, the code grows WASM memory and places host-managed buffers at the very end of linear memory. If the WASM module's heap allocator has already grown into that region, this will silently overwrite guard-owned memory.

The safest fix is to require alloc/dealloc exports, returning a clear error for guards that don't provide them. The existing error message for missing guard functions provides a good model:

if allocFn == nil {
    return nil, fmt.Errorf("WASM module must export alloc and dealloc for safe memory management. " +
        "See examples/guards/sample-guard/README.md for details")
}

Alternatively, document this limitation prominently in the guard authoring guide.

6. isWasmTrap string matching is fragile

The fallback in isWasmTrap uses:

return strings.Contains(err.Error(), "wasm error:")

This will silently break if wazero changes its error message format. Consider opening an upstream issue requesting a typed api.TrapError or similar that can be detected with errors.As. For now, adding a comment noting the wazero version this was verified against would help future maintainers.

Recommendations (Prioritized)

  1. [Medium] Expose CloseGlobalCompilationCache and wire it into graceful shutdown — prevents resource leaks in production (Lpcox/initial implementation #3 above)
  2. [Low] Route WASM warn/error log levels to the operational file logger for better observability (Lpcox/initial implementation #2 above)
  3. [Low] Replace manual LE uint32 decode with mem.ReadUint32Le() — cleaner code (Configure as a Go CLI tool #1 above)
  4. [Future] Require alloc/dealloc exports or document heap-overlap risk in guard authoring guide (Updated Dockerfile #5 above)
  5. [Future] Consider interpreter fallback option for restricted environments (Lpcox/add difc #4 above)

Next Steps

  • Add CloseGlobalCompilationCache(ctx) to internal/guard/wasm.go and call it in server/unified.go shutdown
  • Update hostLog to route warn/error to logger.LogWarn/logger.LogError
  • Replace manual uint32 LE decoding with mem.ReadUint32Le()
  • Add guidance to guard authoring docs about alloc/dealloc exports being required for safe memory management

Generated by Go Fan 🐹
Module summary saved to: specs/mods/wazero.md (session artifact)
Run: §24983047267

Note

🔒 Integrity filter blocked 8 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by Go Fan · ● 2.7M ·

  • expires on May 4, 2026, 7:58 AM UTC

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions