Skip to content

Commit fd55c22

Browse files
Harden Stockfish init caching and toast behavior
1 parent 72ca23e commit fd55c22

3 files changed

Lines changed: 175 additions & 60 deletions

File tree

src/contexts/StockfishEngineContext.tsx

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ import toast from 'react-hot-toast'
1010
import { StockfishStatus, StockfishEngine } from 'src/types'
1111
import Engine from 'src/lib/engine/stockfish'
1212

13+
const STOCKFISH_LOADING_TOAST_DELAY_MS = 800
14+
15+
let sharedClientStockfishEngine: Engine | null = null
16+
17+
const getOrCreateStockfishEngine = (): Engine => {
18+
if (typeof window === 'undefined') {
19+
return new Engine()
20+
}
21+
22+
if (
23+
!sharedClientStockfishEngine ||
24+
(sharedClientStockfishEngine.initializationError &&
25+
!sharedClientStockfishEngine.ready &&
26+
!sharedClientStockfishEngine.initializing)
27+
) {
28+
sharedClientStockfishEngine = new Engine()
29+
}
30+
31+
return sharedClientStockfishEngine
32+
}
33+
1334
export const StockfishEngineContext = React.createContext<StockfishEngine>({
1435
streamEvaluations: () => {
1536
throw new Error(
@@ -32,13 +53,18 @@ export const StockfishEngineContextProvider: React.FC<{
3253
children: ReactNode
3354
}> = ({ children }: { children: ReactNode }) => {
3455
const engineRef = useRef<Engine | null>(null)
35-
const [status, setStatus] = useState<StockfishStatus>('loading')
36-
const [error, setError] = useState<string | null>(null)
37-
const toastId = useRef<string | null>(null)
38-
3956
if (!engineRef.current) {
40-
engineRef.current = new Engine()
57+
engineRef.current = getOrCreateStockfishEngine()
4158
}
59+
const [status, setStatus] = useState<StockfishStatus>(() => {
60+
if (engineRef.current?.initializationError) return 'error'
61+
return engineRef.current?.ready ? 'ready' : 'loading'
62+
})
63+
const [error, setError] = useState<string | null>(
64+
() => engineRef.current?.initializationError ?? null,
65+
)
66+
const toastId = useRef<string | null>(null)
67+
const loadingToastTimerRef = useRef<number | null>(null)
4268

4369
const streamEvaluations = useCallback(
4470
(fen: string, legalMoveCount: number, depth?: number) => {
@@ -61,10 +87,16 @@ export const StockfishEngineContextProvider: React.FC<{
6187

6288
useEffect(() => {
6389
const checkEngineStatus = () => {
64-
if (engineRef.current?.ready) {
90+
const engine = engineRef.current
91+
if (!engine) return
92+
93+
if (engine.initializationError) {
94+
setStatus('error')
95+
setError(engine.initializationError)
96+
} else if (engine.ready) {
6597
setStatus('ready')
6698
setError(null)
67-
} else if (engineRef.current && !engineRef.current.ready) {
99+
} else {
68100
setStatus('loading')
69101
}
70102
}
@@ -78,33 +110,63 @@ export const StockfishEngineContextProvider: React.FC<{
78110
// Toast notifications for Stockfish engine status
79111
useEffect(() => {
80112
return () => {
81-
toast.dismiss()
113+
if (loadingToastTimerRef.current !== null && typeof window !== 'undefined') {
114+
window.clearTimeout(loadingToastTimerRef.current)
115+
loadingToastTimerRef.current = null
116+
}
117+
if (toastId.current) {
118+
toast.dismiss(toastId.current)
119+
toastId.current = null
120+
}
82121
}
83122
}, [])
84123

85124
useEffect(() => {
86-
if (status === 'loading' && !toastId.current) {
87-
toastId.current = toast.loading('Loading Stockfish Engine...')
88-
} else if (status === 'ready') {
125+
if (status === 'loading') {
126+
if (
127+
toastId.current ||
128+
loadingToastTimerRef.current !== null ||
129+
typeof window === 'undefined'
130+
) {
131+
return
132+
}
133+
134+
loadingToastTimerRef.current = window.setTimeout(() => {
135+
loadingToastTimerRef.current = null
136+
if (!toastId.current && engineRef.current && !engineRef.current.ready) {
137+
toastId.current = toast.loading('Loading Stockfish Engine...')
138+
}
139+
}, STOCKFISH_LOADING_TOAST_DELAY_MS)
140+
return
141+
}
142+
143+
if (loadingToastTimerRef.current !== null && typeof window !== 'undefined') {
144+
window.clearTimeout(loadingToastTimerRef.current)
145+
loadingToastTimerRef.current = null
146+
}
147+
148+
if (status === 'ready') {
149+
// Only show a success toast when we previously showed a loading toast.
89150
if (toastId.current) {
90151
toast.success('Loaded Stockfish! Engine is ready', {
91152
id: toastId.current,
92153
})
93154
toastId.current = null
94-
} else {
95-
toast.success('Loaded Stockfish! Engine is ready')
96155
}
97156
} else if (status === 'error') {
157+
const message = error
158+
? `Failed to load Stockfish engine: ${error}`
159+
: 'Failed to load Stockfish engine'
98160
if (toastId.current) {
99-
toast.error('Failed to load Stockfish engine', {
161+
toast.error(message, {
100162
id: toastId.current,
101163
})
102164
toastId.current = null
103165
} else {
104-
toast.error('Failed to load Stockfish engine')
166+
toast.error(message)
105167
}
106168
}
107-
}, [status])
169+
}, [status, error])
108170

109171
const contextValue = useMemo(
110172
() => ({

src/lib/engine/stockfish.ts

Lines changed: 91 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import StockfishWeb from 'lila-stockfish-web'
44
import { StockfishEvaluation } from 'src/types'
55
import { StockfishModelStorage } from './stockfishStorage'
66

7+
const DEFAULT_NNUE_FETCH_TIMEOUT_MS = 30000
8+
79
class Engine {
810
private fen: string
911
private moves: string[]
@@ -21,6 +23,8 @@ class Engine {
2123
private evaluationRejecter: ((reason?: unknown) => void) | null
2224
private evaluationPromise: Promise<StockfishEvaluation> | null
2325
private evaluationGenerator: AsyncGenerator<StockfishEvaluation> | null
26+
private initError: string | null
27+
private initInFlight: boolean
2428

2529
constructor() {
2630
this.fen = ''
@@ -33,9 +37,17 @@ class Engine {
3337
this.evaluationRejecter = null
3438
this.evaluationPromise = null
3539
this.evaluationGenerator = null
40+
this.initError = null
41+
this.initInFlight = false
3642

3743
this.onMessage = this.onMessage.bind(this)
3844

45+
// Skip browser-only Stockfish initialization during SSR/Node renders.
46+
if (typeof window === 'undefined' || typeof Worker === 'undefined') {
47+
return
48+
}
49+
50+
this.initInFlight = true
3951
setupStockfish()
4052
.then((stockfish: StockfishWeb) => {
4153
this.stockfish = stockfish
@@ -44,26 +56,41 @@ class Engine {
4456
stockfish.uci('setoption name MultiPV value 100')
4557
stockfish.onError = this.onError
4658
stockfish.listen = this.onMessage
59+
this.initError = null
4760
this.isReady = true
4861
this.nnueLoaded = true
4962
})
5063
.catch((error) => {
5164
console.error('Failed to initialize Stockfish:', error)
65+
this.initError =
66+
error instanceof Error ? error.message : 'Unknown initialization error'
5267
this.isReady = false
68+
this.nnueLoaded = false
69+
})
70+
.finally(() => {
71+
this.initInFlight = false
5372
})
5473
}
5574

5675
get ready(): boolean {
5776
return this.isReady && this.stockfish !== null && this.nnueLoaded
5877
}
5978

79+
get initializationError(): string | null {
80+
return this.initError
81+
}
82+
83+
get initializing(): boolean {
84+
return this.initInFlight
85+
}
86+
6087
async *streamEvaluations(
6188
fen: string,
6289
legalMoveCount: number,
6390
targetDepth = 18,
6491
): AsyncGenerator<StockfishEvaluation> {
6592
if (this.stockfish && this.isReady) {
66-
if (typeof global.gc === 'function') {
93+
if (typeof global !== 'undefined' && typeof global.gc === 'function') {
6794
global.gc()
6895
}
6996

@@ -340,16 +367,47 @@ const sharedWasmMemory = (lo: number, hi = 32767): WebAssembly.Memory => {
340367
}
341368
}
342369

370+
const getNnueFetchTimeoutMs = (): number => {
371+
const raw = process.env.NEXT_PUBLIC_STOCKFISH_NNUE_FETCH_TIMEOUT_MS
372+
const parsed = raw ? parseInt(raw, 10) : NaN
373+
return Number.isFinite(parsed) && parsed > 0
374+
? parsed
375+
: DEFAULT_NNUE_FETCH_TIMEOUT_MS
376+
}
377+
378+
const fetchWithTimeout = async (
379+
url: string,
380+
timeoutMs: number,
381+
): Promise<Response> => {
382+
if (typeof AbortController === 'undefined' || timeoutMs <= 0) {
383+
return fetch(url)
384+
}
385+
386+
const controller = new AbortController()
387+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
388+
try {
389+
return await fetch(url, { signal: controller.signal })
390+
} catch (error) {
391+
if (error instanceof DOMException && error.name === 'AbortError') {
392+
throw new Error(`Stockfish NNUE fetch timed out after ${timeoutMs}ms: ${url}`)
393+
}
394+
throw error
395+
} finally {
396+
clearTimeout(timeoutId)
397+
}
398+
}
399+
343400
const loadNnueModel = async (
344401
modelUrl: string,
345402
storage: StockfishModelStorage,
403+
timeoutMs: number,
346404
): Promise<ArrayBuffer> => {
347405
const cachedModel = await storage.getModel(modelUrl)
348406
if (cachedModel) {
349407
return cachedModel
350408
}
351409

352-
const response = await fetch(modelUrl)
410+
const response = await fetchWithTimeout(modelUrl, timeoutMs)
353411
if (!response.ok) {
354412
throw new Error(
355413
`Failed to fetch Stockfish NNUE model (${response.status}) from ${modelUrl}`,
@@ -361,45 +419,38 @@ const loadNnueModel = async (
361419
return buffer
362420
}
363421

364-
const setupStockfish = (): Promise<StockfishWeb> => {
365-
return new Promise<StockfishWeb>((resolve, reject) => {
366-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
367-
import('lila-stockfish-web/sf17-79.js').then((makeModule: any) => {
368-
makeModule
369-
.default({
370-
wasmMemory: sharedWasmMemory(2560),
371-
onError: (msg: string) => reject(new Error(msg)),
372-
locateFile: (name: string) => `/stockfish/${name}`,
373-
})
374-
.then(async (instance: StockfishWeb) => {
375-
// NNUE weights served via raw.githubusercontent.com permalink (CORS + COEP compatible).
376-
// Override with NEXT_PUBLIC_STOCKFISH_NNUE_BASE_URL for self-hosted deployments.
377-
const nnueBaseUrl =
378-
process.env.NEXT_PUBLIC_STOCKFISH_NNUE_BASE_URL ??
379-
'https://raw.githubusercontent.com/CSSLab/maia-platform-frontend/e23a50e/public/stockfish'
380-
const storage = new StockfishModelStorage()
381-
await storage.requestPersistentStorage()
382-
383-
const nnue0Url = `${nnueBaseUrl}/${instance.getRecommendedNnue(0)}`
384-
const nnue1Url = `${nnueBaseUrl}/${instance.getRecommendedNnue(1)}`
385-
386-
// Load NNUE models before resolving
387-
Promise.all([
388-
loadNnueModel(nnue0Url, storage),
389-
loadNnueModel(nnue1Url, storage),
390-
])
391-
.then((buffers) => {
392-
instance.setNnueBuffer(new Uint8Array(buffers[0]), 0)
393-
instance.setNnueBuffer(new Uint8Array(buffers[1]), 1)
394-
resolve(instance)
395-
})
396-
.catch((error) => {
397-
console.error('Failed to load NNUE models:', error)
398-
reject(error)
399-
})
400-
})
401-
})
422+
const setupStockfish = async (): Promise<StockfishWeb> => {
423+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
424+
const makeModule: any = await import('lila-stockfish-web/sf17-79.js')
425+
const instance: StockfishWeb = await makeModule.default({
426+
wasmMemory: sharedWasmMemory(2560),
427+
locateFile: (name: string) => `/stockfish/${name}`,
402428
})
429+
430+
// NNUE weights served via raw.githubusercontent.com permalink (CORS + COEP compatible).
431+
// Override with NEXT_PUBLIC_STOCKFISH_NNUE_BASE_URL for self-hosted deployments.
432+
const nnueBaseUrl =
433+
process.env.NEXT_PUBLIC_STOCKFISH_NNUE_BASE_URL ??
434+
'https://raw.githubusercontent.com/CSSLab/maia-platform-frontend/e23a50e/public/stockfish'
435+
const storage = new StockfishModelStorage()
436+
await storage.requestPersistentStorage()
437+
438+
const nnue0Url = `${nnueBaseUrl}/${instance.getRecommendedNnue(0)}`
439+
const nnue1Url = `${nnueBaseUrl}/${instance.getRecommendedNnue(1)}`
440+
const timeoutMs = getNnueFetchTimeoutMs()
441+
442+
try {
443+
const buffers = await Promise.all([
444+
loadNnueModel(nnue0Url, storage, timeoutMs),
445+
loadNnueModel(nnue1Url, storage, timeoutMs),
446+
])
447+
instance.setNnueBuffer(new Uint8Array(buffers[0]), 0)
448+
instance.setNnueBuffer(new Uint8Array(buffers[1]), 1)
449+
return instance
450+
} catch (error) {
451+
console.error('Failed to load NNUE models:', error)
452+
throw error
453+
}
403454
}
404455

405456
export default Engine

src/pages/_app.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ const openSansClassName = 'font-sans'
3838
function MaiaPlatform({ Component, pageProps }: AppProps) {
3939
const router = useRouter()
4040
const isAnalysisPage = router.pathname.startsWith('/analysis')
41-
const isPageWithAnalysis = [
41+
const isPageWithMaia = [
4242
'/analysis',
4343
'/openings',
4444
'/puzzles',
4545
'/settings',
4646
'/broadcast',
4747
].some((path) => router.pathname.includes(path))
48+
const isPageWithStockfish = ['/analysis', '/openings', '/puzzles', '/broadcast'].some(
49+
(path) => router.pathname.includes(path),
50+
)
4851

4952
useEffect(() => {
5053
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, {
@@ -67,9 +70,8 @@ function MaiaPlatform({ Component, pageProps }: AppProps) {
6770
AuthContextProvider,
6871
ErrorBoundary,
6972
ModalContextProvider,
70-
...(isPageWithAnalysis
71-
? [MaiaEngineContextProvider, StockfishEngineContextProvider]
72-
: []),
73+
...(isPageWithMaia ? [MaiaEngineContextProvider] : []),
74+
...(isPageWithStockfish ? [StockfishEngineContextProvider] : []),
7375
...(isAnalysisPage ? [AnalysisListContextProvider] : []),
7476
]}
7577
>

0 commit comments

Comments
 (0)