subscribeRepos accepts a cursor ahead of the head seq, sends nothing and returns no error. The spec says to send FutureCursor and close the connection. Separately, zds can't start its seq above a previous host's. Together these mean zds can't replace another PDS on the same hostname: the relays reconnect with their stored cursor, and the account's events never reach the network.
Reproduction #
Tested on 9750848. Start a local zds, create one account, open subscribeRepos, then write a post. This was run twice and gave the same result both times:
cursor=none: frames=1 open
cursor=73716: frames=0 open
With cursor 73716 there's no error frame and the connection stays open. subscriptionStart (src/atproto/sync.zig) takes the cursor as given, and listSeqEvents selects WHERE seq > ?, so nothing is delivered until the local seq passes the cursor.
repro.sh and listen.py
#!/usr/bin/env bash
# repro: a subscriber whose cursor is ahead of zds's head seq receives nothing, silently
set -euo pipefail
zds_root="$1"
here=$(cd "$(dirname "$0")" && pwd)
work=$(mktemp -d "${TMPDIR:-/tmp}/zds-seqfloor.XXXXXX")
port=2595; plc_port=2596; base="http://127.0.0.1:$port"
cleanup() { kill ${zds_pid:-} ${plc_pid:-} 2>/dev/null || true; wait 2>/dev/null || true; }
trap cleanup EXIT
python3 -c "
from http.server import BaseHTTPRequestHandler, HTTPServer
class S(BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get('content-length', 0))); self.send_response(200); self.end_headers()
def do_GET(self):
self.send_response(404); self.end_headers()
def log_message(self, *a): pass
HTTPServer(('127.0.0.1', $plc_port), S).serve_forever()
" &
plc_pid=$!
ZDS_PLC_ROTATION_KEY=1111111111111111111111111111111111111111111111111111111111111111 \
ZDS_ALLOW_PRIVATE_FETCH=true "$zds_root/zig-out/bin/zds" \
--host 127.0.0.1 --port "$port" --jwt-secret repro --db "$work/zds.sqlite3" \
--blobstore-path "$work/blobs" --public-url "$base" --server-did did:web:localhost \
--handle-domains .test --invite-required --admin-token repro-admin \
--operator-handle op.test --plc-directory "http://127.0.0.1:$plc_port" >"$work/zds.log" 2>&1 &
zds_pid=$!
curl -fsS --retry 50 --retry-delay 0 --retry-all-errors "$base/xrpc/_health" >/dev/null
code=$(curl -fsS "$base/xrpc/com.atproto.server.createInviteCode" -H 'authorization: Bearer repro-admin' \
-H 'content-type: application/json' -d '{"useCount":1}' | jq -r .code)
acct=$(curl -fsS "$base/xrpc/com.atproto.server.createAccount" -H 'content-type: application/json' \
-d "{\"handle\":\"alice.test\",\"email\":\"a@test.com\",\"password\":\"hunter2hunter2\",\"inviteCode\":\"$code\"}")
jwt=$(jq -r .accessJwt <<<"$acct"); did=$(jq -r .did <<<"$acct")
post() {
curl -fsS "$base/xrpc/com.atproto.repo.createRecord" -H "authorization: Bearer $jwt" -H 'content-type: application/json' \
-d "{\"repo\":\"$did\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"\$type\":\"app.bsky.feed.post\",\"text\":\"$1\",\"createdAt\":\"2026-11-09T00:00:00Z\"}}" >/dev/null
}
post warmup
echo "zds 0.x at $(git -C "$zds_root" rev-parse --short HEAD)"
for cursor in none 73716; do
uv run --quiet --with websockets python3 "$here/listen.py" "$base" "$cursor" >"$work/listen-$cursor.txt" &
lp=$!
sleep 1
post "after-connect-$cursor"
wait $lp || true
echo "cursor=$cursor: $(cat "$work/listen-$cursor.txt")"
done
import asyncio
import sys
import websockets
async def main(base: str, cursor: str) -> None:
url = base.replace("http", "ws") + "/xrpc/com.atproto.sync.subscribeRepos"
if cursor != "none":
url += f"?cursor={cursor}"
frames = 0
closed = "open"
async with websockets.connect(url) as ws:
try:
while True:
await asyncio.wait_for(ws.recv(), timeout=4)
frames += 1
except TimeoutError:
pass
except websockets.ConnectionClosed as e:
closed = f"closed code={e.code} reason={e.reason!r}"
print(f"frames={frames} {closed}")
asyncio.run(main(sys.argv[1], sys.argv[2]))
Run it as bash repro.sh ~/path/to/zds after zig build smoke-build. It needs uv for websockets.
What the spec and the reference implementations do #
- The spec. The event-stream spec says that when the cursor is higher than the current seq, the server sends an error and closes the connection.
com.atproto.sync.subscribeReposdeclaresFutureCursor. - The reference PDS.
packages/pds/src/api/com/atproto/sync/subscribeRepos.tsthrowsInvalidRequestError('Cursor in the future.', 'FutureCursor'). - The indigo relay.
cmd/relay/relay/slurper.gosets the host toidleand drops it onFutureCursor.SubscribeToHostandResubscribeAllHoststhen reconnect with the storedlast_seq, so the host stays stuck until a relay operator resets it.
Why it matters now #
I want to replace the Bluesky reference PDS at pds.zzstoatzz.io with zds on the same hostname. Today both bsky.network and relay1.us-east.bsky.network report getHostStatus seq: 73716 for that host. A fresh zds starts at 1:
- With the current behaviour, every event for zzstoatzz.io is dropped silently until the zds seq passes 73716.
- With
FutureCursoradded, the relays mark the host idle and stop instead.
Neither works without an operator stepping in.
Proposed fix #
- Return
FutureCursorand close whencursor > current_seq. That makes the problem visible instead of silently dropping events. - Add a startup option to set a seq floor (for example
ZDS_SEQ_FLOOR=<n>), so the first sequenced event gets a seq aboven. An operator replacing a host on the same hostname sets it from the old host'sgetHostStatusseq, or anything above it.
Related: #23, the memory issue, which also gates this move.
Fixed in 0.7.7 (
0e0040e, live on pds.zat.dev). A cursor ahead of the latest seq now gets aFutureCursorerror frame and a close (verified in production: one frame, close code 1008).ZDS_SEQ_FLOOR/--seq-floorstarts sequencing above a given seq; set it at or above the highestgetHostStatusseq the relays report for the old host before switching the hostname. See the operator guide, "replacing a host on the same hostname".