atproto pds pds.zat.dev

sync: subscribeRepos ignores future cursors silently; no seq floor for replacing a host in place #25

Closed zzstoatzz.io
Closed opened by zzstoatzz.io

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.subscribeRepos declares FutureCursor.
  • The reference PDS. packages/pds/src/api/com/atproto/sync/subscribeRepos.ts throws InvalidRequestError('Cursor in the future.', 'FutureCursor').
  • The indigo relay. cmd/relay/relay/slurper.go sets the host to idle and drops it on FutureCursor. SubscribeToHost and ResubscribeAllHosts then reconnect with the stored last_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 FutureCursor added, the relays mark the host idle and stop instead.

Neither works without an operator stepping in.

Proposed fix #

  1. Return FutureCursor and close when cursor > current_seq. That makes the problem visible instead of silently dropping events.
  2. Add a startup option to set a seq floor (for example ZDS_SEQ_FLOOR=<n>), so the first sequenced event gets a seq above n. An operator replacing a host on the same hostname sets it from the old host's getHostStatus seq, 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 a FutureCursor error frame and a close (verified in production: one frame, close code 1008). ZDS_SEQ_FLOOR / --seq-floor starts sequencing above a given seq; set it at or above the highest getHostStatus seq the relays report for the old host before switching the hostname. See the operator guide, "replacing a host on the same hostname".

Sign up or Sign in to add to the discussion.