siyuan-note/siyuan · error

WebSocket handshake contains a body

Error message

WebSocket handshake contains a body

What it means

For PluginServiceWebSocket endpoints, ValidatePluginServiceResponse enforces that a successful HTTP 101 Switching Protocols handshake carries no response body. If the status is 101 and the payload is non-empty, the validator returns "WebSocket handshake contains a body". A 101 response with a body is malformed per RFC 6455 and would corrupt the frame stream that follows.

Solutions

  1. Remove any w.Write/Flush calls on the connection when the upgrade to 101 succeeds
  2. Ensure middleware wrapping the WebSocket route does not buffer or append body bytes
  3. Check reverse-proxy configuration so 101 responses pass through without an injected error body
  4. Return 400 or 500 with the body instead of 101 if you need to report an error before upgrading

Example fix

// before
if upgraded { w.Write([]byte("hello")) }
// after
if upgraded { /* write frames only, never body bytes on a 101 */ }
Defensive patterns

Strategy: try-catch

Validate before calling

func handshakeIsClean(status int, payload []byte) bool { return status != 101 || len(payload) == 0 }

Try / catch

if err := bundle.ValidatePluginServiceResponse(method, path, PluginServiceWebSocket, status, ct, payload); err != nil { if strings.Contains(err.Error(), "WebSocket handshake contains a body") { dropConnectionAndLog(err); return }; return err }

Prevention

When it happens

Trigger: Calling Bundle.ValidatePluginServiceResponse with mode PluginServiceWebSocket, status 101, and a non-empty payload — typically a handler that writes data to the connection before or during the upgrade, or a reverse proxy that appends an error body to an upgraded response.

Common situations: The plugin handler calls w.Write alongside the Hijack/upgrade sequence; a middleware (logging, compression) wraps the connection and injects bytes; an intermediary proxies an upstream 101 but attaches its own error text; the upgrade succeeds conditionally but buffered output is flushed anyway.

Understand the failure class

Related errors


AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19). Data as JSON: /api/errors/cb4c55f6b61b4d71. Report an issue: GitHub.

Appendix: source

Thrown at kernel/apicontract/plugin_service_protocol.go:230

		valid := json.Valid(payload)
		if tail, ok := strings.CutSuffix(string(payload), ");"); ok {
			for index, char := range tail {
				if char == '(' && json.Valid([]byte(tail[index+1:])) {
					valid = true
					break
				}
			}
		}
		if !valid {
			return fmt.Errorf("invalid plugin JSONP response")
		}
	case PluginServiceSecureJSON:
		if !json.Valid(payload) && !json.Valid([]byte(strings.TrimPrefix(string(payload), "while(1);"))) {
			return fmt.Errorf("invalid plugin secure JSON response")
		}
	case PluginServiceWebSocket:
		if status == 101 && len(payload) != 0 {
			return fmt.Errorf("WebSocket handshake contains a body")
		}
	case PluginServiceXML:
		decoder := xml.NewDecoder(strings.NewReader(string(payload)))
		for {
			if _, err := decoder.Token(); err != nil {
				if err == io.EOF {
					break
				}
				return err
			}
		}
	case PluginServiceYAML:
		var value yaml.Node
		if err := yaml.Unmarshal(payload, &value); err != nil {
			return err
		}
	case PluginServiceTOML:
		var value map[string]any

View on GitHub (pinned to 9f775e8a12)