detached c755163

Merge db488ae1ab840b4e610d642af02752a2e9599350 into b3b55869a5643ad700fe1bb454fb63ab92cc56af

commit
c755163d7dc374af5f3a3b133eaacd22cb22cd42
author
ermite · · 2026-10-22 14:56 UTC
merge of
b3b5586 + db488ae · diff against the first parent
signature
unsigned
4 files · +40 -7
M internal/presentation/vkdevice/device.go +2 -0
@@ -55,6 +55,7 @@ type Device struct {
5555 Validation *Validation
5656 atlas *glyphAtlas
5757 images *imageCache
58+ imageDimension int // application ceiling clamped to the selected device
5859 }
5960
6061 func Open(main Identity) (_ *Device, err error) {
@@ -136,6 +137,7 @@ func Open(main Identity) (_ *Device, err error) {
136137 }
137138 }
138139 d.Physical = candidate
140+ d.imageDimension = int(min(uint32(maxImageDimension), props.Properties.Limits.MaxImageDimension2D))
139141 d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)}
140142 break
141143 }
@@ -55,6 +55,7 @@ type Device struct {
55 Validation *Validation55 Validation *Validation
56 atlas *glyphAtlas56 atlas *glyphAtlas
57 images *imageCache57 images *imageCache
58+ imageDimension int // application ceiling clamped to the selected device
58 }59 }
59 60
60 func Open(main Identity) (_ *Device, err error) {61 func Open(main Identity) (_ *Device, err error) {
@@ -136,6 +137,7 @@ func Open(main Identity) (_ *Device, err error) {
136 }137 }
137 }138 }
138 d.Physical = candidate139 d.Physical = candidate
140+ d.imageDimension = int(min(uint32(maxImageDimension), props.Properties.Limits.MaxImageDimension2D))
139 d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)}141 d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)}
140 break142 break
141 }143 }
M internal/presentation/vkdevice/image_cache.go +3 -2
@@ -87,6 +87,7 @@ func (c *imageCache) dispose(entries []*imageEntry) {
8787 }
8888 func (d *Device) newImageCache() (c *imageCache, err error) {
8989 c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})}
90+ c.policy.maxDimension = d.imageDimension
9091 defer func() {
9192 if err != nil {
9293 c.Close()
@@ -169,8 +170,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) {
169170 seen[key] = true
170171 count++
171172 b := key.Bounds()
172- if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
173- return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), maxImageDimension)
173+ if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > c.policy.maxDimension || b.Dy() > c.policy.maxDimension {
174+ return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), c.policy.maxDimension)
174175 }
175176 if e := c.policy.entries[key]; e != nil {
176177 bytes += e.bytes
@@ -87,6 +87,7 @@ func (c *imageCache) dispose(entries []*imageEntry) {
87 }87 }
88 func (d *Device) newImageCache() (c *imageCache, err error) {88 func (d *Device) newImageCache() (c *imageCache, err error) {
89 c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})}89 c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})}
90+ c.policy.maxDimension = d.imageDimension
90 defer func() {91 defer func() {
91 if err != nil {92 if err != nil {
92 c.Close()93 c.Close()
@@ -169,8 +170,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) {
169 seen[key] = true170 seen[key] = true
170 count++171 count++
171 b := key.Bounds()172 b := key.Bounds()
172- if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {173+ if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > c.policy.maxDimension || b.Dy() > c.policy.maxDimension {
173- return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), maxImageDimension)174+ return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), c.policy.maxDimension)
174 }175 }
175 if e := c.policy.entries[key]; e != nil {176 if e := c.policy.entries[key]; e != nil {
176 bytes += e.bytes177 bytes += e.bytes
M internal/presentation/vkdevice/image_cache_policy.go +5 -4
@@ -9,7 +9,7 @@ import (
99 const maxImageTextures = 64
1010 const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget
1111 const maxImageBytes = 64 << 20
12-const maxImageDimension = 4096
12+const maxImageDimension = 8192
1313
1414 type imageEntry struct {
1515 key image.Image
@@ -26,6 +26,7 @@ type imageEntry struct {
2626 // only then may UpdateDescriptorSets overwrite the recycled set. The pool is
2727 // destroyed once at shutdown. This avoids requiring FreeDescriptorSets.
2828 type imageCachePolicy struct {
29+ maxDimension int
2930 entries map[image.Image]*imageEntry
3031 free []int
3132 retired []*imageEntry
@@ -34,7 +35,7 @@ type imageCachePolicy struct {
3435 }
3536
3637 func newImageCachePolicy() *imageCachePolicy {
37- p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry)}
38+ p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry), maxDimension: maxImageDimension}
3839 // Slot 0 is permanently reserved for the valid set 1 fallback.
3940 for i := imageDescriptorSlots - 1; i >= 1; i-- {
4041 p.free = append(p.free, i)
@@ -72,8 +73,8 @@ func (p *imageCachePolicy) reserve(key image.Image) (*imageEntry, []*imageEntry,
7273 }
7374 bounds := key.Bounds()
7475 w, h := bounds.Dx(), bounds.Dy()
75- if w <= 0 || h <= 0 || w > maxImageDimension || h > maxImageDimension {
76- return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, maxImageDimension)
76+ if w <= 0 || h <= 0 || w > p.maxDimension || h > p.maxDimension {
77+ return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, p.maxDimension)
7778 }
7879 if e := p.entries[key]; e != nil {
7980 e.used, e.lastSerial = p.frame, p.frame
@@ -9,7 +9,7 @@ import (
9 const maxImageTextures = 649 const maxImageTextures = 64
10 const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget10 const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget
11 const maxImageBytes = 64 << 2011 const maxImageBytes = 64 << 20
12-const maxImageDimension = 409612+const maxImageDimension = 8192
13 13
14 type imageEntry struct {14 type imageEntry struct {
15 key image.Image15 key image.Image
@@ -26,6 +26,7 @@ type imageEntry struct {
26 // only then may UpdateDescriptorSets overwrite the recycled set. The pool is26 // only then may UpdateDescriptorSets overwrite the recycled set. The pool is
27 // destroyed once at shutdown. This avoids requiring FreeDescriptorSets.27 // destroyed once at shutdown. This avoids requiring FreeDescriptorSets.
28 type imageCachePolicy struct {28 type imageCachePolicy struct {
29+ maxDimension int
29 entries map[image.Image]*imageEntry30 entries map[image.Image]*imageEntry
30 free []int31 free []int
31 retired []*imageEntry32 retired []*imageEntry
@@ -34,7 +35,7 @@ type imageCachePolicy struct {
34 }35 }
35 36
36 func newImageCachePolicy() *imageCachePolicy {37 func newImageCachePolicy() *imageCachePolicy {
37- p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry)}38+ p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry), maxDimension: maxImageDimension}
38 // Slot 0 is permanently reserved for the valid set 1 fallback.39 // Slot 0 is permanently reserved for the valid set 1 fallback.
39 for i := imageDescriptorSlots - 1; i >= 1; i-- {40 for i := imageDescriptorSlots - 1; i >= 1; i-- {
40 p.free = append(p.free, i)41 p.free = append(p.free, i)
@@ -72,8 +73,8 @@ func (p *imageCachePolicy) reserve(key image.Image) (*imageEntry, []*imageEntry,
72 }73 }
73 bounds := key.Bounds()74 bounds := key.Bounds()
74 w, h := bounds.Dx(), bounds.Dy()75 w, h := bounds.Dx(), bounds.Dy()
75- if w <= 0 || h <= 0 || w > maxImageDimension || h > maxImageDimension {76+ if w <= 0 || h <= 0 || w > p.maxDimension || h > p.maxDimension {
76- return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, maxImageDimension)77+ return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, p.maxDimension)
77 }78 }
78 if e := p.entries[key]; e != nil {79 if e := p.entries[key]; e != nil {
79 e.used, e.lastSerial = p.frame, p.frame80 e.used, e.lastSerial = p.frame, p.frame
M internal/presentation/vkdevice/image_cache_policy_test.go +30 -1
@@ -42,11 +42,40 @@ func TestImageCacheBudgetAndFenceReuse(t *testing.T) {
4242 if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") {
4343 t.Fatalf("expected identity error: %v", err)
4444 }
45- if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, 4097, 1))); err == nil || !strings.Contains(err.Error(), "maximum") {
45+ if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, maxImageDimension+1, 1))); err == nil || !strings.Contains(err.Error(), "maximum") {
4646 t.Fatalf("expected size error: %v", err)
4747 }
4848 }
4949
50+func TestImageCacheWideImageAndDeviceLimit(t *testing.T) {
51+ p := newImageCachePolicy()
52+ p.begin(1, 0)
53+ // Bounds alone suffice: reserve validates before reading pixels.
54+ wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)}
55+ if _, _, err := p.reserve(wide); err != nil {
56+ t.Fatalf("ultrawide image: %v", err)
57+ }
58+ p.maxDimension = 4096
59+ if _, _, err := p.reserve(wide); err == nil || !strings.Contains(err.Error(), "maximum 4096") {
60+ t.Fatalf("expected device limit: %v", err)
61+ }
62+ p.maxDimension = maxImageDimension
63+ tooLarge := &image.RGBA{Rect: image.Rect(0, 0, 7680, 4320)}
64+ if _, _, err := p.reserve(tooLarge); err == nil || !strings.Contains(err.Error(), "budget") {
65+ t.Fatalf("expected unchanged byte budget: %v", err)
66+ }
67+}
68+
69+func TestImageCacheResolveDeviceLimit(t *testing.T) {
70+ p := newImageCachePolicy()
71+ p.maxDimension = 4096
72+ c := &imageCache{policy: p}
73+ wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)}
74+ if _, err := c.resolve(&Frame{}, []Batch{{Source: wide}}); err == nil || !strings.Contains(err.Error(), "maximum 4096") {
75+ t.Fatalf("expected frame validation against device limit: %v", err)
76+ }
77+}
78+
5079 func TestImageCacheLRUAndBytes(t *testing.T) {
5180 p := newImageCachePolicy()
5281 old := image.NewRGBA(image.Rect(0, 0, 2048, 2048))
@@ -42,11 +42,40 @@ func TestImageCacheBudgetAndFenceReuse(t *testing.T) {
42 if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") {42 if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") {
43 t.Fatalf("expected identity error: %v", err)43 t.Fatalf("expected identity error: %v", err)
44 }44 }
45- if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, 4097, 1))); err == nil || !strings.Contains(err.Error(), "maximum") {45+ if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, maxImageDimension+1, 1))); err == nil || !strings.Contains(err.Error(), "maximum") {
46 t.Fatalf("expected size error: %v", err)46 t.Fatalf("expected size error: %v", err)
47 }47 }
48 }48 }
49 49
50+func TestImageCacheWideImageAndDeviceLimit(t *testing.T) {
51+ p := newImageCachePolicy()
52+ p.begin(1, 0)
53+ // Bounds alone suffice: reserve validates before reading pixels.
54+ wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)}
55+ if _, _, err := p.reserve(wide); err != nil {
56+ t.Fatalf("ultrawide image: %v", err)
57+ }
58+ p.maxDimension = 4096
59+ if _, _, err := p.reserve(wide); err == nil || !strings.Contains(err.Error(), "maximum 4096") {
60+ t.Fatalf("expected device limit: %v", err)
61+ }
62+ p.maxDimension = maxImageDimension
63+ tooLarge := &image.RGBA{Rect: image.Rect(0, 0, 7680, 4320)}
64+ if _, _, err := p.reserve(tooLarge); err == nil || !strings.Contains(err.Error(), "budget") {
65+ t.Fatalf("expected unchanged byte budget: %v", err)
66+ }
67+}
68+
69+func TestImageCacheResolveDeviceLimit(t *testing.T) {
70+ p := newImageCachePolicy()
71+ p.maxDimension = 4096
72+ c := &imageCache{policy: p}
73+ wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)}
74+ if _, err := c.resolve(&Frame{}, []Batch{{Source: wide}}); err == nil || !strings.Contains(err.Error(), "maximum 4096") {
75+ t.Fatalf("expected frame validation against device limit: %v", err)
76+ }
77+}
78+
50 func TestImageCacheLRUAndBytes(t *testing.T) {79 func TestImageCacheLRUAndBytes(t *testing.T) {
51 p := newImageCachePolicy()80 p := newImageCachePolicy()
52 old := image.NewRGBA(image.Rect(0, 0, 2048, 2048))81 old := image.NewRGBA(image.Rect(0, 0, 2048, 2048))

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 1

Tags 12

↑↓ moveenter openesc close