v0.33.2 57f21d6

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close