跳到正文
DEV Community· SynerWorlds·· 3 小時前AI 評分55

SynerWorlds 修復 KataGo 瀏覽器版部署,令引擎開始提供棋步

We Run KataGo in the Browser — After Shipping It Broken

AI 導讀

SynerWorlds 修復其瀏覽器圍棋遊戲的 KataGo 整合,隔離整個子網域後,KataGo 才能在正式環境提供棋步。此前,僅為 iframe 子頁設定 COOP/COEP 標頭無法令頁面跨來源隔離,KataGo 因而未能啟動,遊戲一直靜默使用舊引擎。修改後的正式環境探測測試通過 37/37 項檢查,舊引擎後備方案亦能在隔離設定下運作。

正文

We recently shipped KataGo — the open-source Go engine that plays at superhuman level — as the default AI in our browser Go game. The code was in production, the assets were deployed, the acceptance tests had passed in our test topology. Then a verification pass read the engine's own session state on the live site and found this:

state: "unavailable", failReason: "not cross-origin isolated", queryCount: 0

KataGo had never played a single move. Every game, at every level, had been silently served by the old engine — which played well enough that nobody noticed. This post is about how we got real KataGo running in the browser, why the first integration could never have worked, and what the fix cost us (surprisingly little, once we understood the problem).

The setup: a good engine with a low ceiling

Our Go game at covay.synerworlds.com offers 12 difficulty levels. Before KataGo, the stack was split in two:

  • Levels 1–9: GNU Go compiled to WASM, with graduated blunder injection at the bottom.
  • Levels 10–12: a ~2-dan neural net served through TensorFlow.js, searched by a clean-room PUCT loop we wrote in TypeScript — roughly 40 evaluations per second, 19×19 boards only, and it required WebGL.

It worked, but the ceiling was obvious: a strong amateur would beat our top level, and 9×9 and 13×13 boards couldn't use the neural path at all.

KataGo (github.com/lightvector/KataGo) removes the ceiling. In our lab, unmodified KataGo v1.18.2 (Eigen CPU backend) at just 200 visits per move beat GNU Go level 10 in two full games — both by resignation, with margins of roughly 45 and 165 points. Its licensing is also unusually friendly for a project like ours: the engine code is MIT and the g170 networks are CC0, so unlike our GPL engine integrations (Stockfish, Fairy-Stockfish) there is no corresponding-source duty. We compiled it with Emscripten 3.1.64 and lazy-load the assets only when a game against the computer starts: about 13.4 MB on the wire for the larger net (b10), 6.1 MB for the smaller one (b6), cached across sessions.

KataGo needs threads. Threads need isolation.

One hard fact drives everything else in this story: KataGo cannot run without OS threads. Its search and neural-eval thread pools are structural, not optional. We verified this the direct way — we built a no-pthread WASM variant, and while it compiled and answered version, it aborted during engine initialization. There is no patched-around version of this; removing the threading would mean forking upstream, a maintenance trap we refused.

Emscripten pthreads in a browser means SharedArrayBuffer, and SharedArrayBuffer means the document must be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp (or credentialless).

Our design tried to confine that blast radius. The main game pages would stay header-free — analytics, realtime, push and portal framing should never be exposed to COEP breakage — and only a tiny engine host page, /kata-host.html, would carry the isolation headers and run inside a same-origin iframe. The game would talk to it over postMessage. In the spike, with an isolated parent page, this passed 12/12 acceptance checks.

The trap: isolation doesn't inherit downward

What we got wrong is a spec fact that is obvious in hindsight: crossOriginIsolated is a property of the top-level document. An iframe does not become cross-origin isolated because it was served with COOP/COEP headers if its parent page is not isolated. Our host page dutifully reported {isolated: false, sab: false} on every load, the session preflight (which checks isolation before downloading a single byte) correctly refused to start, and the fallback stack played on. The headers on /kata-host.html were perfect. They were also irrelevant.

The uncomfortable part isn't the bug — it's that the failure mode was silence. No errors, no broken pages, no user reports. A superhuman engine that never starts looks exactly like a decent engine that does.

The fix: isolate the whole subdomain

The owner's call was to isolate the covay subdomain entirely. That scope turned out to be forced anyway: the app is a single document — one page component with play, rooms, setup, history and the rest as client-side views — so "isolate only the play screen" was never actually possible.

// next.config.ts — one catch-all rule
{
  source: "/:path*",
  headers: [
    { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
    { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
    { key: "Cross-Origin-Resource-Policy", value: "same-origin" },
  ],
},

Two decisions inside that diff are worth explaining:

  • require-corp, not credentialless. Firefox never becomes cross-origin isolated under credentialless. Choosing it would have silently kept an entire browser family on the old engine — and silent non-events are exactly what we were trying to stop having. Safari's require-corp support (15.2+) is also older and broader.
  • We audited every cross-origin load before flipping the switch. Analytics, the TF.js fallback, our i18n fetches and Supabase calls all survived — fetches made in CORS mode don't need a CORP header under COEP. The audit found exactly one casualty: avatar images, loaded as plain <img> tags from Supabase storage, which sends no CORP header. Adding crossOrigin="anonymous" at the three avatar sites (storage sends Access-Control-Allow-Origin: *, so CORS-mode image loads pass) fixed it. Voice chat was a non-issue: LiveKit signaling is a WebSocket and the voice itself is WebRTC — transports, not subresource loads, so COEP doesn't govern them at all.

After the flip, the production probe suite passed 37/37 checks: the document reports crossOriginIsolated === true, real KataGo serves moves and hints at all levels, and the fallback stack still works under isolation.

Running a superhuman engine politely

Some production details that might be useful if you embed a heavy engine:

  • The engine is a stateless oracle. Our TypeScript rules core owns the game; each query replays the move history into the engine. We measured a 60-move replay at effectively zero cost, which makes mid-game fallback and engine respawns safe by construction.
  • Preflight before download. If self.crossOriginIsolated is false or SharedArrayBuffer is undefined, we skip KataGo for the session and download nothing.
  • A 40-visit probe calibrates the device. On startup, one genmove on a canned 30-move position measures visits/second, and the whole 12-level ladder scales from that number. Level 1 targets 300 ms and 2 visits with the temperature turned up (2.2) so it makes human-scale mistakes; level 12 targets 12 seconds with a 100,000-visit ceiling and a 15-second hard cap. On our deliberately weak 2-core probe VM, the session measured ~15 visits/second and picked the smaller b6 net — real hardware picks b10 and runs several times faster.
  • Degrade before you fall back. If the probe is slow: compress visits, then halve search threads, then swap b10 → b6. Only if b6 still yields under ~4 visits/second does the session drop to the legacy stack.
  • Trust, but referee. Every engine move is legality-checked by the rules core. The analysis engine never resigns, so we synthesize resignation (winrate below 0.02 at 150+ visits). A dead worker gets one respawn; a second death halves the visit scale; a third ends the session back on the fallback.

What broke / what we learned

  • Header-based isolation on a child frame is a no-op. If the top document isn't isolated, nothing below it is. Test the topology you'll actually ship, not a convenient variant of it.
  • Our Emscripten glue needed a patch. The stock proxied-stdin design deadlocks against the KataGo analysis engine's separate writer thread; the host page feeds stdin through a SharedArrayBuffer ring buffer instead.
  • Engine failures present as silence. A WASM abort doesn't throw at you — the worker just never answers. KataGo's analysis protocol has the same personality: malformed queries are reported only in its log file, never on stdout. Watchdogs and response timeouts everywhere are not paranoia; they are the only error channel you get.
  • A boring fallback is what makes an ambitious default safe. Because the old stack could serve every level unchanged, isolating a whole subdomain was a reversible, low-drama change. Browsers that can't isolate — Safari older than 15.2, for instance — get exactly the game that existed before the change. Worst case is the status quo, never a broken page.

Try it

KataGo now answers every level of our Go game, from a deliberately sloppy level 1 to the full-strength top of the ladder:

  • Play Go against it: covay.synerworlds.com
  • The rest of the (free, no-download) collection — chess against Stockfish, xiangqi against Fairy-Stockfish, Vietnamese folk games and more: synerworlds.com

If you've embedded a pthreads WASM engine somewhere unusual — or been bitten by isolation behaving differently than you expected — I'd like to hear how it went.

— Bella, SynerWorlds

來源:DEV Community · dev.to