scheme_handler_assets_p2_test.go RawHistoryFolder
1package cef
2
3// P2.2/P2.3 tests: the handler serves the WASM from its captured bundle
4// through a deferred handler that never blocks CEF's IO thread.
5// Replacement installs a fresh isolated state; in-flight requests keep the
6// old bytes; canceled waiters suppress only their own continuation.
7
8import (
9 "bytes"
10 "context"
11 "io/fs"
12 "net/http"
13 "strings"
14 "sync"
15 "testing"
16 "testing/fstest"
17 "time"
18
19 "github.com/andybalholm/brotli"
20 purecef "github.com/bnema/purego-cef/cef"
21 cefmocks "github.com/bnema/purego-cef/cef/mocks"
22 "github.com/stretchr/testify/require"
23
24 "github.com/bnema/dumber/assets"
25)
26
27// blockingReadFileFS blocks ReadFile of one name until release is closed,
28// proving non-blocking Create and replacement isolation during a decode.
29type blockingReadFileFS struct {
30 inner fstest.MapFS
31 blockName string
32 entered chan struct{}
33 release chan struct{}
34 once sync.Once
35}
36
37func (b *blockingReadFileFS) gate(name string) {
38 if name == b.blockName {
39 b.once.Do(func() { close(b.entered) })
40 <-b.release
41 }
42}
43
44func (b *blockingReadFileFS) Open(name string) (fs.File, error) {
45 b.gate(name)
46 return b.inner.Open(name)
47}
48
49func (b *blockingReadFileFS) ReadFile(name string) ([]byte, error) {
50 b.gate(name)
51 return b.inner.ReadFile(name)
52}
53
54// contStubCallback counts native continuations without mock machinery.
55type contStubCallback struct {
56 mu sync.Mutex
57 conts int
58}
59
60func (c *contStubCallback) Cont() {
61 c.mu.Lock()
62 defer c.mu.Unlock()
63 c.conts++
64}
65
66func (c *contStubCallback) Cancel() {}
67
68func (c *contStubCallback) count() int {
69 c.mu.Lock()
70 defer c.mu.Unlock()
71 return c.conts
72}
73
74// testManifestBytes builds a parseable manifest fixture pinning dummy
75// digests. Serving validates presence and format, not truth: digest truth
76// is P3.3's job against real artifacts.
77func testManifestBytes(t *testing.T) []byte {
78 t.Helper()
79 data, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
80 "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
81 "systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
82 "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
83 }}.Bytes()
84 require.NoError(t, err)
85 return data
86}
87
88func wasmURL(t *testing.T) string {
89 t.Helper()
90 return "https://dumber.invalid/systemviews.wasm"
91}
92
93// awaitWASMHandler drives one deferred WASM response to completion on the
94// test goroutine. Open must return promptly even while the decode is
95// blocked: that prompt return is the P2.3 IO-thread guarantee.
96func awaitWASMHandler(t *testing.T, rh purecef.ResourceHandler) *systemviewWASMResourceHandler {
97 t.Helper()
98 wh, ok := rh.(*systemviewWASMResourceHandler)
99 require.True(t, ok, "expected deferred WASM handler, got %T", rh)
100 opened := make(chan int32, 1)
101 go func() { opened <- wh.Open(nil, nil, nil) }()
102 select {
103 case v := <-opened:
104 require.Equal(t, int32(1), v)
105 case <-time.After(10 * time.Second):
106 t.Fatal("Open blocked: the CEF IO thread would stall")
107 }
108 select {
109 case <-wh.done:
110 case <-time.After(30 * time.Second):
111 t.Fatal("WASM load did not complete")
112 }
113 return wh
114}
115
116func TestAssetP2_HandlerServesWASMFromBundleOnce(t *testing.T) {
117 stubIdentityResourceHandler(t)
118 wasm := []byte("\x00asm-handler-once")
119 fsys := &countingFS{inner: fstest.MapFS{
120 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
121 }}
122 h := newTestDumbSchemeHandler(t)
123 h.setAssets(fsys)
124
125 for i := range 2 {
126 wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
127 require.Equal(t, http.StatusOK, wh.statusCode, "request %d", i)
128 require.Equal(t, wasm, wh.data)
129 }
130 require.Equal(t, 1, fsys.compressedReads(),
131 "handler must share the bundle decode across requests")
132}
133
134func TestAssetP2_ReplacementDuringBlockedDecode(t *testing.T) {
135 stubIdentityResourceHandler(t)
136 wasmA := []byte("\x00asm-bundle-a")
137 fsA := &blockingReadFileFS{
138 inner: fstest.MapFS{
139 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmA)},
140 },
141 blockName: "systemviews/systemviews.wasm.br",
142 entered: make(chan struct{}),
143 release: make(chan struct{}),
144 }
145 h := newTestDumbSchemeHandler(t)
146 h.setAssets(fsA)
147
148 u := mustParseURL(t, wasmURL(t))
149 created := make(chan purecef.ResourceHandler, 1)
150 go func() { created <- h.handleAsset(u) }()
151
152 var oldRH purecef.ResourceHandler
153 select {
154 case oldRH = <-created:
155 case <-time.After(10 * time.Second):
156 t.Fatal("Create blocked on decode: the CEF IO thread would stall")
157 }
158 oldWH, ok := oldRH.(*systemviewWASMResourceHandler)
159 require.True(t, ok)
160 opened := make(chan int32, 1)
161 go func() { opened <- oldWH.Open(nil, nil, nil) }()
162 select {
163 case <-opened:
164 case <-time.After(10 * time.Second):
165 t.Fatal("Open blocked on decode")
166 }
167 <-fsA.entered
168
169 // Replace the bundle while A is still decoding: the in-flight request
170 // must keep A's bytes and never publish into the new cache.
171 wasmB := []byte("\x00asm-bundle-b")
172 h.setAssets(fstest.MapFS{
173 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmB)},
174 })
175 close(fsA.release)
176
177 select {
178 case <-oldWH.done:
179 case <-time.After(30 * time.Second):
180 t.Fatal("old load did not complete after release")
181 }
182 require.Equal(t, http.StatusOK, oldWH.statusCode)
183 require.Equal(t, wasmA, oldWH.data, "in-flight request must retain the replaced bundle's bytes")
184
185 fresh := awaitWASMHandler(t, h.handleAsset(u))
186 require.Equal(t, http.StatusOK, fresh.statusCode)
187 require.Equal(t, wasmB, fresh.data, "post-replacement requests must serve the new bundle")
188}
189
190func TestAssetP2_UnconfiguredAssetsServe500(t *testing.T) {
191 stubIdentityResourceHandler(t)
192 h := newTestDumbSchemeHandler(t)
193
194 rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
195 require.Equal(t, http.StatusInternalServerError, rh.statusCode)
196
197 // Installing nil stays a clean 500, never a panic.
198 h.setAssets(nil)
199 rh = staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
200 require.Equal(t, http.StatusInternalServerError, rh.statusCode)
201}
202
203func TestAssetP2_NonWASMAssetsKeepLegacyPath(t *testing.T) {
204 stubIdentityResourceHandler(t)
205 h := newTestDumbSchemeHandler(t)
206 h.setAssets(fstest.MapFS{
207 "systemviews/index.html": {Data: []byte(`<html><script src="./wasm_exec.js"></script></html>`)},
208 "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
209 })
210
211 rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
212 require.Equal(t, http.StatusOK, rh.statusCode)
213 require.Contains(t, string(rh.data), "./wasm_exec.js?v="+strings.Repeat("c", 64))
214}
215
216func TestAssetP23_ManifestReadableDuringBlockedDecode(t *testing.T) {
217 stubIdentityResourceHandler(t)
218 wasm := []byte("\x00asm-manifest-race")
219 blocking := &blockingReadFileFS{
220 inner: fstest.MapFS{
221 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
222 "systemviews/index.html": {Data: []byte(versionedShellFixture)},
223 "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
224 },
225 blockName: "systemviews/systemviews.wasm.br",
226 entered: make(chan struct{}),
227 release: make(chan struct{}),
228 }
229 h := newTestDumbSchemeHandler(t)
230 h.setAssets(blocking)
231
232 // Start a wasm decode and leave it blocked off-thread.
233 firstWH := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
234 require.Equal(t, int32(1), firstWH.Open(nil, nil, nil))
235 <-blocking.entered
236
237 // The shell consults the manifest synchronously on the request path:
238 // it must serve promptly instead of deadlocking behind the decode.
239 served := make(chan purecef.ResourceHandler, 1)
240 go func() { served <- h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")) }()
241 select {
242 case rh := <-served:
243 shell := staticHandlerOf(t, rh)
244 require.Equal(t, http.StatusOK, shell.statusCode)
245 require.Contains(t, string(shell.data), "?v=")
246 case <-time.After(10 * time.Second):
247 t.Fatal("shell deadlocked behind in-flight decode")
248 }
249
250 close(blocking.release)
251 select {
252 case <-firstWH.done:
253 case <-time.After(30 * time.Second):
254 t.Fatal("WASM load did not complete after release")
255 }
256 require.Equal(t, wasm, firstWH.data)
257}
258
259func TestAssetP23_BlockedDecodeLeavesUnrelatedWorkUnaffected(t *testing.T) {
260 stubIdentityResourceHandler(t)
261 wasm := []byte("\x00asm-unrelated")
262 blocking := &blockingReadFileFS{
263 inner: fstest.MapFS{
264 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
265 "systemviews/index.html": {Data: []byte("<html>shell</html>")},
266 "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
267 },
268 blockName: "systemviews/systemviews.wasm.br",
269 entered: make(chan struct{}),
270 release: make(chan struct{}),
271 }
272 h := newTestDumbSchemeHandler(t)
273 h.setAssets(blocking)
274
275 // Shell traffic before any WASM request must not decode.
276 shell := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
277 require.Equal(t, http.StatusOK, shell.statusCode)
278
279 u := mustParseURL(t, wasmURL(t))
280 created := make(chan purecef.ResourceHandler, 1)
281 go func() { created <- h.handleAsset(u) }()
282 var firstRH purecef.ResourceHandler
283 select {
284 case firstRH = <-created:
285 case <-time.After(10 * time.Second):
286 t.Fatal("Create blocked on decode")
287 }
288 firstWH := firstRH.(*systemviewWASMResourceHandler)
289 opened := make(chan int32, 1)
290 go func() { opened <- firstWH.Open(nil, nil, nil) }()
291 select {
292 case <-opened:
293 case <-time.After(10 * time.Second):
294 t.Fatal("Open blocked on decode")
295 }
296 <-blocking.entered
297
298 // A second waiter joins the same in-flight decode while an unrelated
299 // API request completes without touching the decoder.
300 secondWH := h.handleAsset(u).(*systemviewWASMResourceHandler)
301 require.Equal(t, int32(1), secondWH.Open(nil, nil, nil))
302 apiRH := staticHandlerOf(t, h.handleAPI(nil, http.MethodOptions, "/api/message", nil))
303 require.Equal(t, http.StatusNoContent, apiRH.statusCode)
304
305 select {
306 case <-firstWH.done:
307 t.Fatal("decode completed while still blocked")
308 case <-time.After(100 * time.Millisecond):
309 }
310
311 close(blocking.release)
312 for _, wh := range []*systemviewWASMResourceHandler{firstWH, secondWH} {
313 select {
314 case <-wh.done:
315 case <-time.After(30 * time.Second):
316 t.Fatal("WASM load did not complete after release")
317 }
318 require.Equal(t, http.StatusOK, wh.statusCode)
319 require.Equal(t, wasm, wh.data, "both waiters share the one decode")
320 }
321}
322
323func TestAssetP23_CancelSuppressesContinuation(t *testing.T) {
324 stubIdentityResourceHandler(t)
325 wasm := []byte("\x00asm-cancel")
326 blocking := &blockingReadFileFS{
327 inner: fstest.MapFS{
328 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
329 },
330 blockName: "systemviews/systemviews.wasm.br",
331 entered: make(chan struct{}),
332 release: make(chan struct{}),
333 }
334 h := newTestDumbSchemeHandler(t)
335 h.setAssets(blocking)
336
337 cb := &contStubCallback{}
338 wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
339 require.Equal(t, int32(1), wh.Open(nil, nil, cb))
340 <-blocking.entered
341
342 // Canceling one waiter suppresses only its own continuation; the shared
343 // decode still completes for the bundle.
344 wh.Cancel()
345 close(blocking.release)
346 select {
347 case <-wh.done:
348 case <-time.After(30 * time.Second):
349 t.Fatal("canceled load did not finish")
350 }
351 require.Equal(t, http.StatusOK, wh.statusCode)
352 require.Equal(t, wasm, wh.data)
353 require.Zero(t, cb.count(), "canceled waiter must not resume CEF")
354
355 // A later waiter on the same bundle is served from the completed decode
356 // and resumes normally.
357 cb2 := &contStubCallback{}
358 wh2 := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
359 require.Equal(t, int32(1), wh2.Open(nil, nil, cb2))
360 select {
361 case <-wh2.done:
362 case <-time.After(30 * time.Second):
363 t.Fatal("post-cancel waiter did not complete")
364 }
365 require.Equal(t, wasm, wh2.data)
366 require.Equal(t, 1, cb2.count(), "later waiter must resume CEF normally")
367}
368
369func TestAssetP23_ShutdownCompletesSafely(t *testing.T) {
370 stubIdentityResourceHandler(t)
371 wasm := []byte("\x00asm-shutdown")
372 blocking := &blockingReadFileFS{
373 inner: fstest.MapFS{
374 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
375 },
376 blockName: "systemviews/systemviews.wasm.br",
377 entered: make(chan struct{}),
378 release: make(chan struct{}),
379 }
380 ctx, cancel := context.WithCancel(context.Background())
381 h, err := newDumbSchemeHandler(ctx, nil, noopConfigPayload(), noopConfigPayload())
382 require.NoError(t, err)
383 h.setAssets(blocking)
384
385 wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
386 cb := &contStubCallback{}
387 require.Equal(t, int32(1), wh.Open(nil, nil, cb))
388 <-blocking.entered
389
390 // Engine shutdown mid-decode: the bounded CPU decode still finishes
391 // without hanging or panicking, but the native continuation is
392 // suppressed because the request context died with the engine.
393 cancel()
394 close(blocking.release)
395 select {
396 case <-wh.done:
397 case <-time.After(30 * time.Second):
398 t.Fatal("load hung across shutdown")
399 }
400 require.Equal(t, http.StatusOK, wh.statusCode)
401 require.Equal(t, wasm, wh.data)
402 // done closes before the continuation decision; give the decision a
403 // scheduling window, then prove Cont never fired.
404 require.Never(t, func() bool { return cb.count() > 0 }, 500*time.Millisecond, 10*time.Millisecond,
405 "shutdown must suppress the native continuation")
406}
407
408func TestAssetP23_WireUnversionedCarriesNoStore(t *testing.T) {
409 stubIdentityResourceHandler(t)
410 wasm := []byte("\x00asm-wire")
411 h := newTestDumbSchemeHandler(t)
412 h.setAssets(fstest.MapFS{
413 "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
414 })
415
416 wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
417
418 response := cefmocks.NewMockResponse(t)
419 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
420 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
421 response.EXPECT().SetMimeType("application/wasm").Once()
422 // Unversioned successes stay explicitly noncacheable; no immutable
423 // header may appear without a verified version.
424 response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
425 var responseLength int64
426 wh.GetResponseHeaders(response, &responseLength, 0)
427 require.Equal(t, int64(len(wasm)), responseLength)
428}
429
430func TestAssetP23_WireFailureMatchesErrorPage(t *testing.T) {
431 stubIdentityResourceHandler(t)
432 h := newTestDumbSchemeHandler(t)
433 h.setAssets(fstest.MapFS{
434 "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
435 })
436
437 wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
438 require.Equal(t, http.StatusNotFound, wh.statusCode)
439 require.Equal(t, errorPageBody(http.StatusNotFound, "Asset not found"), wh.data,
440 "deferred failure must stay byte-identical to the synchronous error page")
441
442 response := cefmocks.NewMockResponse(t)
443 response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
444 response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
445 response.EXPECT().SetMimeType("text/html").Once()
446 response.EXPECT().SetCharset("utf-8").Once()
447 var responseLength int64
448 wh.GetResponseHeaders(response, &responseLength, 0)
449 require.Equal(t, int64(len(wh.data)), responseLength)
450}
451
452// wasmBenchFixture builds a 2 MiB pseudo-WASM payload; zeros keep brotli
453// fast so benchmarks measure plumbing, not compression ratios.
454func wasmBenchFixture() []byte {
455 return bytes.Repeat([]byte("\x00asm-bench"), 256*1024)
456}
457
458func brotliCompressForBench(b *testing.B, data []byte) []byte {
459 b.Helper()
460 var buf bytes.Buffer
461 w := brotli.NewWriter(&buf)
462 if _, err := w.Write(data); err != nil {
463 b.Fatalf("brotli write: %v", err)
464 }
465 if err := w.Close(); err != nil {
466 b.Fatalf("brotli close: %v", err)
467 }
468 return buf.Bytes()
469}
470
471// BenchmarkSystemviewWASMFirstLoad measures one cold bundle decode; the
472// cached benchmark below measures the shared path every request takes.
473func BenchmarkSystemviewWASMFirstLoad(b *testing.B) {
474 wasm := wasmBenchFixture()
475 compressed := brotliCompressForBench(b, wasm)
476 b.ResetTimer()
477 for i := 0; i < b.N; i++ {
478 b.StopTimer()
479 bundle := newSystemviewAssetBundle(fstest.MapFS{
480 "systemviews/systemviews.wasm.br": {Data: compressed},
481 })
482 b.StartTimer()
483 data, err := bundle.WASM()
484 if err != nil || len(data) != len(wasm) {
485 b.Fatalf("load: %v", err)
486 }
487 }
488}
489
490func BenchmarkSystemviewWASMCached(b *testing.B) {
491 wasm := wasmBenchFixture()
492 bundle := newSystemviewAssetBundle(fstest.MapFS{
493 "systemviews/systemviews.wasm.br": {Data: brotliCompressForBench(b, wasm)},
494 })
495 if _, err := bundle.WASM(); err != nil {
496 b.Fatalf("warmup load: %v", err)
497 }
498 b.ResetTimer()
499 for i := 0; i < b.N; i++ {
500 data, err := bundle.WASM()
501 if err != nil || len(data) != len(wasm) {
502 b.Fatalf("load: %v", err)
503 }
504 }
505}