mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
- wrap writePixels in state.storage.transactionSync so a partial multi-chunk failure doesn't leave the canvas half-written or out of sync with the WS broadcast - size new chunk buffer against chunkSize(chunkId) instead of the persisted blob's length so writes after a canvas-grow no longer silently drop OOB bytes in the formerly-last short chunk - refund the cooldown row when writePixels throws so transient storage errors stop soft-DOSing the user (and halving image-uploader throughput) - bound readAllChunks by chunk_id < CHUNK_COUNT and trim oversized blobs so orphan rows from a future shrink no longer crash GET /api/canvas - require a positive Content-Length on /api/place (411) and reject above the pre-parse cap (413); previously a missing or zero header bypassed the cap - drop String(err) from the 500 response body - drain the INSERT cursor symmetrically with the UPDATE branch in tryAcquire - assert CHUNK_BYTES <= 2 MB at module load (DO SQLite per-cell BLOB cap) - correct the inverted webSocketClose comment and guard the re-close call - add tests for missing / zero / oversized Content-Length Plan: plans/260510-0232-fix-do-migration-followups/phase-01-do-storage-atomicity.md
7.9 KiB
7.9 KiB
phase, title, status, priority, effort, dependencies
| phase | title | status | priority | effort | dependencies |
|---|---|---|---|---|---|
| 3 | WebSocket hardening & client race fix | pending | P1 | 3h |
Phase 3: WebSocket Hardening & Client Race Fix
Overview
Fix the WebSocket-during-initial-fetch race that silently drops pixels. Add Origin allowlist + per-identity connection cap on WS upgrade. Add a minimal heartbeat so dead connections fire onclose promptly.
Context Links
- Reports:
plans/reports/code-reviewer-260510-0211-rplace-do-migration.md(C2 race, H4 conn cap, M3 heartbeat) - Reports:
plans/reports/debugger-260510-0211-rplace-edge-cases.md(H5 origin/amplification, L6 conn cap, M3 heartbeat — duplicate)
Key Insights
- The pre-allocated
committedColorszero array gets overwritten by the post-fetchnew Uint8Array(indices)— any WS edits between WS-open and fetch-resolve are lost. Fix: buffer WS edits, replay after replacement. - CF DO
state.acceptWebSocket(ws, [tag1, tag2])letsgetWebSockets(tag)filter — perfect for per-identity caps. - Hibernation API may auto-ping at TCP level, but app-level heartbeat is cheaper insurance and gives clients a way to detect zombies.
Requirements
Functional
- WS messages received during the initial canvas fetch are applied (not dropped) once the fetch resolves.
- WS upgrade rejected (403) if
Originheader is present and not in allowlist. - WS upgrade rejected (429) if the requesting identity already has ≥ N (default 5) live sockets.
- Server accepts
pingtext message and respondspong. Client sends ping every 30s; if no pong in 60s, closes WS to trigger reconnect.
Non-functional
- Allowlist configurable via
wrangler.jsonenv vars (ALLOWED_ORIGINS, comma-separated). - Conn cap configurable (
MAX_WS_PER_IDENTITY, default 5).
Architecture
Worker /api/ws
├─ resolve identity (Phase 2 helper)
├─ origin check: if Origin present AND not in env.ALLOWED_ORIGINS → 403
└─ forward to DO with identity in header
CanvasRoom #handleWsUpgrade
├─ existing = state.getWebSockets(identity)
├─ if existing.length >= MAX_WS_PER_IDENTITY → 429
├─ state.acceptWebSocket(server, [identity])
└─ return 101
CanvasRoom webSocketMessage(ws, msg)
├─ if msg === 'ping' → ws.send('pong'); return
└─ else → ws.close(1003, 'unsupported message')
Client (CanvasRenderer.svelte loadCanvas)
let pendingWsEdits = [];
ws.onmessage during fetch → push to pendingWsEdits (don't apply)
fetch resolves:
committedColors = new Uint8Array(indices);
apply pendingWsEdits to committedColors
pendingWsEdits = null
flag "live mode" — onmessage now applies directly
Client (App.svelte)
setInterval(() => ws.send('ping'), 30_000)
trackPongTimer; if no pong in 60s → ws.close()
Related Code Files
Modify
src/worker.js— origin check before WS upgrade forwardingsrc/durable-objects/canvas-room.js— per-identity conn cap, ping handling, tag-aware acceptWebSocketsrc/client/components/CanvasRenderer.svelte— buffer-and-replay during initial fetch (around lines 465–484)src/client/App.svelte— ping interval, pong watchdogsrc/lib/constants.js— addMAX_WS_PER_IDENTITY = 5wrangler.json— addvars: { ALLOWED_ORIGINS: "https://rplace.miti99.workers.dev" }
Create — none
Implementation Steps
-
Origin allowlist in worker (debugger H5)
- Read
env.ALLOWED_ORIGINS(comma-separated). Parse to Set at module top. - In
/api/wshandler: ifOriginheader present and not in allowlist, returnc.text('forbidden_origin', 403). Empty allowlist → allow all (dev default). - Document in
wrangler.jsoncomment.
- Read
-
Per-identity WS cap in DO (review H4, debugger L6)
- In
#handleWsUpgrade(request, identity):const existing = this.state.getWebSockets(identity); - If
existing.length >= MAX_WS_PER_IDENTITY→ returnnew Response('too_many_sockets', { status: 429 }). - Replace
state.acceptWebSocket(server)withstate.acceptWebSocket(server, [identity]).
- In
-
Server-side heartbeat (review M3, debugger M3)
- In
webSocketMessage(ws, message): ifmessage === 'ping'→ws.send('pong'); return;. Else keep current close behavior. - Note: this works under hibernation because messages auto-rehydrate the DO.
- In
-
Client buffer-and-replay (review C2)
- In
loadCanvas(CanvasRenderer.svelte:465-484):- Add
let pendingWsEdits = [];andlet isLive = false;at top ofloadCanvas. - Expose
pushWsEdit(edit)from the component: if!isLive→pendingWsEdits.push(edit); else apply directly. - After fetch resolves and
committedColors = new Uint8Array(indices), replay: for eacheditinpendingWsEdits, write tocommittedColors[edit.idx] = edit.colorAND updateimageData. ThenisLive = true; pendingWsEdits = null;.
- Add
- In parent (
App.svelte), route ws.onmessage pixel events tocanvasRenderer.pushWsEdit(...)instead of applying directly when first connect.
- In
-
Client heartbeat (review M3)
- In
App.svelteWS open handler: startsetInterval(() => ws.readyState === 1 && ws.send('ping'), 30_000). TracklastPongAt = Date.now(). - On message
'pong':lastPongAt = Date.now(). - Watchdog: if
Date.now() - lastPongAt > 60_000→ws.close()to trigger reconnect logic. - Clear interval/watchdog on
onclose.
- In
-
Compile + smoke
npm run buildpasses.- Open dev console, throttle network to "Slow 3G", reload, place pixel from a second tab during fetch, confirm pixel appears in tab 1 once fetch completes.
- Try opening 6 WS connections from same browser → 6th gets 429.
- Try opening WS from a different origin (curl with
Origin: https://evil.example) → 403. - Confirm
ping/ponground-trips in dev console.
Todo List
- Origin allowlist parsing + worker check
- Per-identity WS cap (
MAX_WS_PER_IDENTITY) withacceptWebSocket(server, [identity]) - Server
pinghandler returnspong wrangler.jsonvars.ALLOWED_ORIGINS- Client buffer-and-replay for WS during initial fetch
- Client 30s ping / 60s pong watchdog
npm run buildpasses- Manual smoke: race-fix verified by slow network reload + remote pixel placement
- Manual smoke: 6th WS rejected with 429
- Manual smoke: foreign-origin WS rejected with 403
- Manual smoke: ping/pong visible in dev tools
Success Criteria
- No pixel placed during the initial-fetch window is dropped (verified via instrumented log).
- WS upgrade from disallowed origin returns 403 in production.
- Per-identity cap enforced; logs show
too_many_socketswhen triggered. onclosefires within ~60s of network drop (verified via airplane-mode toggle).- No regression in 94-test suite.
Risk Assessment
- Risk: Origin allowlist set too tight → legitimate clients (preview deployments, custom domains) get 403.
Mitigation: Empty
ALLOWED_ORIGINSallows all — start with empty in dev/preview, populate before production deploy. - Risk: Cap on identity blocks tab-power-users (5 tabs is normal for some folks). Mitigation: Cap is configurable; bump to 10 if support tickets appear.
- Risk: Heartbeat interval too aggressive → battery drain on mobile. Mitigation: 30s ping is well below the typical mobile-radio-wakeup penalty; keeping interval >= 25s avoids extra wakes.
- Risk: Buffer-and-replay logic interacts oddly with the existing
imageDatainvalidation in CanvasRenderer. Mitigation: Replay loop must call the same path the live message handler does (write to bothcommittedColorsANDimageData); add a unit test in Phase 4.
Security Considerations
- Origin check is a usability/cost barrier, not a security one — WS protocol allows non-browser clients to spoof Origin. Real defense is the per-identity cap + Worker request budget.
- Per-identity cap prevents broadcast amplification (debugger H5, L6).
- Heartbeat surface is a single text-equality check; no parser exposure.