v0.33.2 57f21d6

systemview_asset_cache.go RawHistoryFolder

1package cef
2
3// Bundle-scoped immutable systemviews WASM cache (Plan 05 P2.1).
4//
5// Only the successfully decoded immutable systemviews WASM is cached, one
6// entry per asset bundle. This is deliberately NOT a general web response
7// cache: shell HTML, JS/CSS, API responses, and error pages are never
8// cached here.
9//
10// Hardened load semantics (tighter than the legacy readAssetWithEncoding):
11//   - A present compressed artifact is authoritative. Corrupt or oversized
12//     output is an explicit error with no raw fallback.
13//   - The raw fallback applies ONLY when the compressed file is absent
14//     (errors.Is(err, fs.ErrNotExist)). Permission/I/O failures reading
15//     the compressed file are explicit errors even if valid raw data
16//     exists.
17//   - Raw reads are size-bounded before allocation, like the decompressed
18//     path.
19//   - The first outcome (success or failure) is cached for the bundle
20//     lifetime, so a corrupt bundle fails fast instead of storming
21//     retries. Production embeds are immutable, so a cached failure cannot
22//     go stale; bundle replacement installs a fresh cache (P2.2).
23//
24// Returned bytes are immutable: callers must not mutate them.
25
26import (
27	"bytes"
28	"context"
29	"errors"
30	"fmt"
31	"io"
32	"io/fs"
33	"net/http"
34	"sync"
35	"unsafe"
36
37	"github.com/andybalholm/brotli"
38	purecef "github.com/bnema/purego-cef/cef"
39
40	"github.com/bnema/dumber/assets"
41)
42
43// systemviewsWASMPath is the fixed bundle-relative WASM served to internal
44// pages. Only this file is cached; nothing else enters this cache.
45const systemviewsWASMPath = "systemviews/systemviews.wasm"
46
47// systemviewAssetBundle is one immutable asset state: an asset filesystem
48// plus its once-loaded WASM outcome. In-flight requests retain their
49// captured bundle; installing a different bundle replaces the whole state.
50// WASM bytes and manifest load under independent mutexes: a stalled decode
51// must never stall manifest reads on the request path, since handleAsset
52// consults the manifest synchronously while decodes run off-thread.
53type systemviewAssetBundle struct {
54	assets fs.FS
55	wasmMu sync.Mutex
56	loaded bool
57	data   []byte
58	// digest is the hex SHA-256 of data, computed once at load: bundle
59	// bytes are immutable, so versioned serving compares this cached
60	// value instead of re-hashing megabytes per request.
61	digest string
62	err    error
63	// manifestLoaded caches the parsed content manifest alongside the
64	// WASM: shell versioning and immutable headers always validate
65	// against the same captured bundle state, never a newer install.
66	manifestMu     sync.Mutex
67	manifestLoaded bool
68	manifest       assets.Manifest
69	manifestErr    error
70}
71
72// newSystemviewAssetBundle captures one immutable asset filesystem. A nil
73// FS is rejected at load time with a plain error, never a panic.
74func newSystemviewAssetBundle(assetsFS fs.FS) *systemviewAssetBundle {
75	return &systemviewAssetBundle{assets: assetsFS}
76}
77
78// WASM returns the bundle's decoded WASM, loading it exactly once. Success
79// and failure are both cached: repeated callers share the first outcome
80// without re-reading or re-decoding.
81func (b *systemviewAssetBundle) WASM() ([]byte, error) {
82	b.wasmMu.Lock()
83	defer b.wasmMu.Unlock()
84	if !b.loaded {
85		b.data, b.err = loadSystemviewWASM(b.assets)
86		if b.err == nil {
87			b.digest = sha256Hex(b.data)
88		}
89		b.loaded = true
90	}
91	return b.data, b.err
92}
93
94// WASMDigest returns the hex SHA-256 of the once-loaded WASM bytes.
95// It shares the load cache with WASM, so digest comparison in the
96// versioned serving path costs a mutex, not a megabytes hash.
97func (b *systemviewAssetBundle) WASMDigest() (string, error) {
98	if _, err := b.WASM(); err != nil {
99		return "", err
100	}
101	b.wasmMu.Lock()
102	defer b.wasmMu.Unlock()
103	return b.digest, nil
104}
105
106// Manifest returns the bundle's parsed content manifest, loading it
107// exactly once. Success and failure are both cached like the WASM bytes:
108// an invalid manifest fails closed for the bundle lifetime instead of
109// flapping between error and unverified content.
110func (b *systemviewAssetBundle) Manifest() (assets.Manifest, error) {
111	b.manifestMu.Lock()
112	defer b.manifestMu.Unlock()
113	if !b.manifestLoaded {
114		b.manifest, b.manifestErr = loadSystemviewManifest(b.assets)
115		b.manifestLoaded = true
116	}
117	return b.manifest, b.manifestErr
118}
119
120// loadSystemviewManifest reads and strictly validates the bundle manifest.
121// A missing, corrupt, or stale manifest is a plain error: callers serve a
122// clear noncacheable error, never unverified immutable content.
123func loadSystemviewManifest(assetsFS fs.FS) (assets.Manifest, error) {
124	if assetsFS == nil {
125		return assets.Manifest{}, errors.New("systemview assets not configured")
126	}
127	data, err := fs.ReadFile(assetsFS, "systemviews/"+assets.ManifestName)
128	if err != nil {
129		return assets.Manifest{}, fmt.Errorf("read asset manifest: %w", err)
130	}
131	return assets.ParseManifest(data)
132}
133
134// loadSystemviewWASM reads and decodes the bundle WASM with the hardened
135// semantics documented above. It performs no caching itself.
136func loadSystemviewWASM(assetsFS fs.FS) ([]byte, error) {
137	if assetsFS == nil {
138		return nil, errors.New("systemview assets not configured")
139	}
140	// Both the compressed input and the raw fallback are size-bounded
141	// before allocation: legitimate bundles ship a few megabytes of
142	// Brotli for up to 64 MiB of WASM, so anything larger is rejected
143	// before it can exhaust memory.
144	compressed, err := readBoundedAsset(assetsFS, systemviewsWASMPath+".br", maxSystemviewsWASMBytes)
145	if err == nil {
146		data, decodeErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
147		if decodeErr != nil {
148			return nil, fmt.Errorf("decompress systemview WASM: %w", decodeErr)
149		}
150		if len(data) > maxSystemviewsWASMBytes {
151			return nil, fmt.Errorf("decompressed systemview WASM exceeds %d bytes", maxSystemviewsWASMBytes)
152		}
153		return data, nil
154	}
155	if !errors.Is(err, fs.ErrNotExist) {
156		return nil, fmt.Errorf("read compressed systemview WASM: %w", err)
157	}
158	return readBoundedAsset(assetsFS, systemviewsWASMPath, maxSystemviewsWASMBytes)
159}
160
161// readBoundedAsset reads one raw asset with maxBytes enforced before
162// unbounded allocation. Missing files surface unwrapped so callers can
163// match fs.ErrNotExist for fallback decisions.
164func readBoundedAsset(assetsFS fs.FS, name string, maxBytes int) ([]byte, error) {
165	f, err := assetsFS.Open(name)
166	if err != nil {
167		return nil, err
168	}
169	defer func() { _ = f.Close() }()
170	data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
171	if err != nil {
172		return nil, fmt.Errorf("read systemview asset %s: %w", name, err)
173	}
174	if len(data) > maxBytes {
175		return nil, fmt.Errorf("systemview asset %s exceeds %d bytes", name, maxBytes)
176	}
177	return data, nil
178}
179
180// ---------------------------------------------------------------------------
181// Deferred WASM resource handler (Plan 05 P2.3)
182// ---------------------------------------------------------------------------
183
184// systemviewWASMResourceHandler serves one WASM request from a captured
185// bundle without blocking CEF's IO thread, following the existing async
186// favicon handler pattern: Open/ProcessRequest return immediately, the
187// bundle-owned once-loading runs on a worker goroutine, and CEF is resumed
188// via the retained callback. The decode itself is bounded CPU work (at most
189// maxSystemviewsWASMBytes of brotli output), so no handler-wide lock is
190// ever held across it and shutdown never waits on it beyond milliseconds.
191// Canceling one request suppresses only its own continuation; other bundle
192// waiters share the same decoded bytes unaffected. A versioned request pins
193// exact bytes: only output hashing to wantDigest earns immutable headers.
194type systemviewWASMResourceHandler struct {
195	cancel     context.CancelFunc
196	cancelOnce sync.Once
197	// ctx scopes the request to the handler lifetime: engine shutdown
198	// cancels it, letting completion suppress the native continuation.
199	// It is released after the continuation decision, never by load
200	// itself, so shutdown stays observable at that point.
201	ctx        context.Context
202	bundle     *systemviewAssetBundle
203	versioned  bool
204	wantDigest string
205	once       sync.Once
206	// settleMu arbitrates the continuation race: exactly one of
207	// request completion or cancellation wins, so a canceled waiter
208	// never resumes CEF while a completed load always does.
209	settleMu    sync.Mutex
210	settled     bool
211	done        chan struct{}
212	data        []byte
213	contentType string
214	statusCode  int
215	offset      int
216}
217
218func newSystemviewWASMResourceHandler(
219	ctx context.Context, bundle *systemviewAssetBundle, versioned bool, wantDigest string,
220) purecef.ResourceHandler {
221	if ctx == nil {
222		ctx = context.Background()
223	}
224	reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // released after the continuation decision or by Cancel
225	return cefNewResourceHandler(&systemviewWASMResourceHandler{
226		cancel:     cancel,
227		ctx:        reqCtx,
228		bundle:     bundle,
229		versioned:  versioned,
230		wantDigest: wantDigest,
231		done:       make(chan struct{}),
232	})
233}
234
235func (rh *systemviewWASMResourceHandler) load() {
236	defer close(rh.done)
237	if rh.bundle == nil {
238		rh.fail()
239		return
240	}
241	data, err := rh.bundle.WASM()
242	if err != nil {
243		rh.fail()
244		return
245	}
246	// A versioned request pins exact bytes: the bundle digest is computed
247	// once at load over the immutable decoded cache, so this comparison
248	// costs a mutex rather than a megabytes hash per request.
249	if rh.versioned {
250		digest, derr := rh.bundle.WASMDigest()
251		if derr != nil || digest != rh.wantDigest {
252			rh.fail()
253			return
254		}
255	}
256	rh.statusCode = http.StatusOK
257	// MIME matches the synchronous asset path exactly: the bundle serves
258	// the same validated systemviews.wasm bytes getMimeType classifies.
259	rh.contentType = getMimeType(systemviewsWASMPath)
260	rh.data = data
261}
262
263// fail serves the same 404 error document as the synchronous error path,
264// including its HTML content type.
265func (rh *systemviewWASMResourceHandler) fail() {
266	rh.statusCode = http.StatusNotFound
267	rh.contentType = "text/html; charset=utf-8"
268	rh.data = errorPageBody(http.StatusNotFound, "Asset not found")
269}
270
271func (rh *systemviewWASMResourceHandler) start(callback purecef.Callback) {
272	rh.once.Do(func() {
273		go func() {
274			rh.load()
275			rh.settleMu.Lock()
276			superseded := rh.settled || rh.ctx.Err() != nil
277			rh.settled = true
278			rh.settleMu.Unlock()
279			rh.cancelRequest()
280			if !superseded && callback != nil {
281				callback.Cont()
282			}
283		}()
284	})
285}
286
287func (rh *systemviewWASMResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
288	if handleRequest != nil {
289		*handleRequest = 0
290	}
291	rh.start(callback)
292	return 1
293}
294
295func (rh *systemviewWASMResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
296	rh.start(callback)
297	return 1
298}
299
300func (rh *systemviewWASMResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
301	<-rh.done
302	response.SetStatus(int32(rh.statusCode))
303	if text := http.StatusText(rh.statusCode); text != "" {
304		response.SetStatusText(text)
305	}
306	mimeType, charset := splitMimeCharset(rh.contentType)
307	response.SetMimeType(mimeType)
308	if charset != "" {
309		response.SetCharset(charset)
310	}
311	// Only version-verified bytes earn immutable headers. Versioned
312	// failures are noncacheable errors; unversioned successes stay
313	// explicitly noncacheable; unversioned failures keep the legacy
314	// headerless error shape.
315	switch {
316	case rh.statusCode == http.StatusOK && rh.versioned:
317		response.SetHeaderByName("Cache-Control", immutableCacheControl, 1)
318	case rh.versioned || rh.statusCode == http.StatusOK:
319		response.SetHeaderByName("Cache-Control", noStoreCacheControl, 1)
320	}
321	if responseLength != nil {
322		*responseLength = int64(len(rh.data))
323	}
324}
325
326func (rh *systemviewWASMResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
327	return 0
328}
329
330func (rh *systemviewWASMResourceHandler) Read(
331	dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback,
332) int32 {
333	<-rh.done
334	if rh.offset >= len(rh.data) {
335		return 0
336	}
337	remaining := len(rh.data) - rh.offset
338	toRead := min(int(bytesToRead), remaining)
339	dst := unsafe.Slice((*byte)(dataOut), toRead)
340	copy(dst, rh.data[rh.offset:rh.offset+toRead])
341	rh.offset += toRead
342	if bytesRead != nil {
343		*bytesRead = int32(toRead)
344	}
345	return 1
346}
347
348func (rh *systemviewWASMResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
349	return 0
350}
351
352func (rh *systemviewWASMResourceHandler) Cancel() {
353	rh.settleMu.Lock()
354	rh.settled = true
355	rh.settleMu.Unlock()
356	rh.cancelRequest()
357}
358
359func (rh *systemviewWASMResourceHandler) cancelRequest() {
360	if rh.cancel != nil {
361		rh.cancelOnce.Do(rh.cancel)
362	}
363}

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