siyuan-note/siyuan · error

WebSocket is not open

Error message

WebSocket is not open (state: %d)

What it means

The plugin WebSocket port's send path checks the tracked readyState before writing. If the port is already CLOSING (1) or CLOSED (2) — i.e. the underlying connection has been upgraded away, closed by the client, or closed by the server — any send attempt returns 'WebSocket is not open (state: %d)' instead of writing. It is the kernel-side equivalent of the DOM 'InvalidStateError: connection is closing' behavior.

Solutions

  1. Check port.readyState === 1 (OPEN) before every send, including inside async callbacks
  2. Track close lifecycle: set a local flag in port.onclose and stop all send loops when it fires
  3. Wrap sends in try/catch and abort the associated async work (clear timers, cancel promises) on failure
  4. For long-lived streams, ping/verify the connection or re-establish it instead of blind sends

Example fix

// before
setInterval(() => port.send(JSON.stringify(progress)), 1000);
// after
const timer = setInterval(() => {
  if (port.readyState !== 1) { clearInterval(timer); return; }
  try { port.send(JSON.stringify(progress)); } catch (e) { clearInterval(timer); }
}, 1000);
Defensive patterns

Strategy: try-catch

Validate before calling

function canSend(port) { return port && port.readyState === 1; }

Type guard

const isWsOpen = (port) => port && typeof port.send === "function" && port.readyState === 1;

Try / catch

try { port.send(msg); } catch (e) { if (String(e).includes("WebSocket is not open")) { stopSendLoop(); scheduleReconnect(); } else { throw e; } }

Prevention

When it happens

Trigger: Calling port.send (or writing from port.onmessage/onopen handlers) after port.close() was called, after the client disconnected, or after the server tore down the connection; sending from a stale async callback that fires after close.

Common situations: Plugin keeps a reference to port and sends heartbeat/progress messages after the client navigated away; async work (setTimeout/promise) completes after the WS client closed; race between onclose firing and an in-flight send loop.

Related errors


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

Appendix: source

Thrown at kernel/plugin/plugin.go:1145

			sendRunErr := p.worker.Run(func(rt *goja.Runtime) (_ any, err error) {
				var messageData []byte
				var opcode gws.Opcode
				if len(sendCall.Arguments) >= 1 {
					data := sendCall.Argument(0)
					if arrayBuffer, ok := data.Export().(goja.ArrayBuffer); ok {
						opcode = gws.OpcodeBinary
						b := arrayBuffer.Bytes()
						messageData = make([]byte, len(b))
						copy(messageData, b) // ArrayBuffer.Bytes() points into JS engine memory; copy before async send
					} else {
						opcode = gws.OpcodeText
						messageData = []byte(data.String())
					}
				}

				state := WebSocketState(readyState.Load())
				if state == WebSocketReadyStateClosing || state == WebSocketReadyStateClosed {
					err = fmt.Errorf("WebSocket is not open (state: %d)", state)
					return
				}

				updatePortBufferedAmount(rt, len(messageData))
				socket.WriteAsync(opcode, messageData, func(writeErr error) {
					p.worker.Run(func(rt *goja.Runtime) (_ any, err error) {
						if writeErr == nil {
							updatePortBufferedAmount(rt, -len(messageData))
						} else {
							err = writeErr
						}
						return
					}, func(rt *goja.Runtime, result any, err error) {
						if lo.IsNil(err) {
							if resolveErr := sendResolve(result); resolveErr != nil {
								logging.LogErrorf("[plugin:%s] ws server port.send resolve: %v", p.Name, resolveErr)
							}
						} else {

View on GitHub (pinned to 9f775e8a12)