Bug Description
Every /zero/* WebSocket connection is closed abruptly by zero-cache roughly
30s after the handshake, in an infinite reconnect loop. The immediate effect
is that any UI backed by Zero (Documents list, notifications, etc.) never
finishes loading and stays stuck on its skeleton state, even though the
REST API works fine and documents are indexed successfully in the background.
Deployment Type
Steps to Reproduce
This reproduces with zero application state — no login, no cookies, no
real client — so it's not specific to any account/workspace/document:
curl -s -i -N --http1.1 --max-time 40 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"https://<your-domain>/zero/sync/v51/connect?clientID=test&clientGroupID=test&userID=test&baseCookie=&ts=0&lmid=0&wsid=test"
Response: 101 Switching Protocols, then the connection sits open for
exactly ~30 seconds and delivers this payload before the socket dies:
Error: Unexpected undefined value
In the real app: log in, open the Documents panel — it shows the skeleton
loading state and never resolves. DevTools console shows a WebSocket
reconnect loop against /zero/sync/v51/connect with ClientError: WebSocket connection closed abruptly, repeating with increasing backoff
forever.
Expected Behavior
The /zero/* WebSocket connection stays open and the Documents panel (and
other Zero-synced views) load normally.
Actual Behavior
zero-cache closes every connection ~30s in. Debug-level logs
(ZERO_LOG_LEVEL=debug) show the actual failure happens almost immediately
after initConnection, not at the 30s mark — the 30s is just ws's default
closeTimeout (node_modules/ws/lib/constants.js: CLOSE_TIMEOUT = 30000)
elapsing while it waits for a clean close handshake that never happens,
before hard-terminating the socket (hence code: 1006, wasClean: false
on the client side).
Relevant debug trace (single connection, clientID=onavlbmb2jcbfahafk):
{"level":"DEBUG",...,"message":"acquired lock for cvr"}
{"level":"DEBUG",...,"cmd":"initConnection","message":"mismatched wsID B-5e8huSLCsTscTm-sCL0"}
{"level":"DEBUG",...,"message":"No auth maintenance wakeup scheduled"}
{"level":"WARN","worker":"dispatcher","message":"closing connection to client with error Error: Unexpected undefined value"}
{"level":"INFO",...,"message":"closing clientGroupID=..."}
{"level":"WARN",...,"errorBody":{"kind":"Internal","message":"shut down before initialization completed","origin":"zeroCache"},"name":"ProtocolError",...}
Traced Error: Unexpected undefined value to shared/src/must.js
(if (v == null) throw new Error(msg ?? \Unexpected ${v} value`)), and the close path to closeWithErrorinzero-cache/src/types/ws.js, called from the dispatcher's handoff error path in zero-cache/src/server/worker-dispatcher.js`.
Wasn't able to pin the exact call site further without unminified source —
happy to dig further if useful, or if there's a source-mapped build I can
point at instead.
Note: the mismatched wsID line in view-syncer.js (client?.wsID !== wsID → debug-log + early return) looks like a benign, expected no-op on
its own — flagging it only because it's the last view-syncer-side log line
before the dispatcher's crash, in case it's a useful breadcrumb.
Things I ruled out (self-hosted repro, all reproduce identically)
- Reverse proxy: replaced bundled Caddy with nginx-proxy-manager,
replicating the exact /zero/context → backend, /zero/* → zero-cache
routing from docker/proxy/Caddyfile. Bypassed the proxy entirely (curl
straight to the zero-cache container on the Docker network) — identical
~30s failure, so this isn't a proxy/header issue.
ZERO_NUM_SYNC_WORKERS: reproduces at both 4 (default) and 1.
- Zero-cache version: reproduces identically on
1.6.0 (the pin in
docker/docker-compose.yml), 1.6.2, and latest (=1.9.0 as of this
writing, confirmed via Docker Hub digest match) — not a regression that
got fixed upstream.
- Stale/desynced Postgres state:
docker compose down -v (wiped
postgres_data and every other named volume), fresh alembic upgrade head on an empty database, brand-new user registration — same failure
from the very first connection attempt.
Environment Information
- Browser: Chrome (latest)
- Operating System: self-hosted server: Ubuntu 24.04 (Docker host); client OS irrelevant, reproduces via bare curl
- SurfSense Version:
ghcr.io/modsetter/surfsense-backend:latest / surfsense-web:latest (pulled 2026-09-04)
Additional Environment Details (for Self-hosted only)
- Database:
pgvector/pgvector:pg17, fresh install, wal_level=logical per bundled postgresql.conf
- Deployment Method: Docker Compose, adapted from
docker/docker-compose.yml — services/env otherwise unchanged (ETL_SERVICE=DOCLING, EMBEDDING_MODEL=litellm://text-embedding-3-small, AUTH_TYPE=LOCAL, SANDBOX_ENABLED=FALSE)
- Only structural deviations from the upstream compose: nginx-proxy-manager instead of bundled Caddy (see above — proven not the cause), and
opensandbox-server/sandbox-image omitted entirely (unrelated services, no dependency on zero-cache)
Additional Context
This blocks real usage, not just a console warning — the Documents panel
(and presumably notifications, chat live-updates, and anything else backed
by Zero per zero-sync.mdx) never leaves its loading skeleton, since it's
sync-only with no REST fallback visible in the UI. Document upload/indexing
via Celery and the REST API works fine in the background; it's specifically
the Zero-synced read paths that never resolve.
Happy to run more diagnostics (e.g. capture the exact WS frames sent by the
real browser client, or bisect zero-cache versions further) — this repro is
fully scripted so turnaround is fast.
Checklist
Bug Description
Every
/zero/*WebSocket connection is closed abruptly byzero-cacheroughly30s after the handshake, in an infinite reconnect loop. The immediate effect
is that any UI backed by Zero (Documents list, notifications, etc.) never
finishes loading and stays stuck on its skeleton state, even though the
REST API works fine and documents are indexed successfully in the background.
Deployment Type
Steps to Reproduce
This reproduces with zero application state — no login, no cookies, no
real client — so it's not specific to any account/workspace/document:
Response:
101 Switching Protocols, then the connection sits open forexactly ~30 seconds and delivers this payload before the socket dies:
In the real app: log in, open the Documents panel — it shows the skeleton
loading state and never resolves. DevTools console shows a WebSocket
reconnect loop against
/zero/sync/v51/connectwithClientError: WebSocket connection closed abruptly, repeating with increasing backoffforever.
Expected Behavior
The
/zero/*WebSocket connection stays open and the Documents panel (andother Zero-synced views) load normally.
Actual Behavior
zero-cachecloses every connection ~30s in. Debug-level logs(
ZERO_LOG_LEVEL=debug) show the actual failure happens almost immediatelyafter
initConnection, not at the 30s mark — the 30s is justws's defaultcloseTimeout(node_modules/ws/lib/constants.js: CLOSE_TIMEOUT = 30000)elapsing while it waits for a clean close handshake that never happens,
before hard-terminating the socket (hence
code: 1006, wasClean: falseon the client side).
Relevant debug trace (single connection,
clientID=onavlbmb2jcbfahafk):Traced
Error: Unexpected undefined valuetoshared/src/must.js(
if (v == null) throw new Error(msg ?? \Unexpected ${v} value`)), and the close path tocloseWithErrorinzero-cache/src/types/ws.js, called from the dispatcher's handoff error path inzero-cache/src/server/worker-dispatcher.js`.Wasn't able to pin the exact call site further without unminified source —
happy to dig further if useful, or if there's a source-mapped build I can
point at instead.
Note: the
mismatched wsIDline inview-syncer.js(client?.wsID !== wsID→ debug-log + earlyreturn) looks like a benign, expected no-op onits own — flagging it only because it's the last view-syncer-side log line
before the dispatcher's crash, in case it's a useful breadcrumb.
Things I ruled out (self-hosted repro, all reproduce identically)
replicating the exact
/zero/context→ backend,/zero/*→ zero-cacherouting from
docker/proxy/Caddyfile. Bypassed the proxy entirely (curlstraight to the
zero-cachecontainer on the Docker network) — identical~30s failure, so this isn't a proxy/header issue.
ZERO_NUM_SYNC_WORKERS: reproduces at both4(default) and1.1.6.0(the pin indocker/docker-compose.yml),1.6.2, andlatest(=1.9.0as of thiswriting, confirmed via Docker Hub digest match) — not a regression that
got fixed upstream.
docker compose down -v(wipedpostgres_dataand every other named volume), freshalembic upgrade headon an empty database, brand-new user registration — same failurefrom the very first connection attempt.
Environment Information
ghcr.io/modsetter/surfsense-backend:latest/surfsense-web:latest(pulled 2026-09-04)Additional Environment Details (for Self-hosted only)
pgvector/pgvector:pg17, fresh install,wal_level=logicalper bundledpostgresql.confdocker/docker-compose.yml— services/env otherwise unchanged (ETL_SERVICE=DOCLING,EMBEDDING_MODEL=litellm://text-embedding-3-small,AUTH_TYPE=LOCAL,SANDBOX_ENABLED=FALSE)opensandbox-server/sandbox-imageomitted entirely (unrelated services, no dependency on zero-cache)Additional Context
This blocks real usage, not just a console warning — the Documents panel
(and presumably notifications, chat live-updates, and anything else backed
by Zero per
zero-sync.mdx) never leaves its loading skeleton, since it'ssync-only with no REST fallback visible in the UI. Document upload/indexing
via Celery and the REST API works fine in the background; it's specifically
the Zero-synced read paths that never resolve.
Happy to run more diagnostics (e.g. capture the exact WS frames sent by the
real browser client, or bisect zero-cache versions further) — this repro is
fully scripted so turnaround is fast.
Checklist