v0.4.0 2de4e1d

session.go RawHistoryFolder

1//go:build linux
2
3// Package session coordinates Wayland ownership, Vulkan submissions, DRM
4// release points and the application frame loop behind nefergui.Run.
5package session
6
7import (
8	"bufio"
9	"context"
10	"encoding/json"
11	"errors"
12	"fmt"
13	"image"
14	"image/png"
15	"os"
16	"path/filepath"
17	"runtime"
18	"sync"
19	"time"
20
21	"github.com/bnema/nefergui/internal/platform/wayland"
22	"github.com/bnema/nefergui/internal/presentation/buffers"
23	"github.com/bnema/nefergui/internal/presentation/syncobj"
24	"github.com/bnema/nefergui/internal/presentation/vkdevice"
25	"github.com/bnema/nefergui/internal/render"
26	"github.com/bnema/nefergui/internal/text"
27	"github.com/bnema/wlturbo/protocol/drmsyncobj"
28	"golang.org/x/sys/unix"
29)
30
31type Slot struct {
32	Buffer          *buffers.Buffer
33	Image           *vkdevice.Image
34	Frame           *vkdevice.Frame
35	First           bool
36	Wait            *vkdevice.BinaryFence
37	PendingWait     *vkdevice.BinaryFence
38	WaitInFlight    bool
39	ReadbackFrame   uint64
40	ReadbackPending bool
41	ReleaseHandle   uint32
42	ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
43}
44type Session struct {
45	Window           *wayland.Window
46	Device           *vkdevice.Device
47	Node             *syncobj.Node
48	Pool             *buffers.Pool
49	Pipeline         *vkdevice.Pipeline
50	ListPipeline     *vkdevice.Pipeline
51	Preparer         *render.Preparer
52	list             render.ListBuffer // owner-loop scratch; see tickFrame
53	Slots            map[uint64]*Slot
54	retiredPipelines []retiredPipelines
55	Acquire          uint32
56	AcquireTimeline  *drmsyncobj.WpLinuxDrmSyncobjTimeline
57	Width, Height    int32
58	Transparent      bool
59	trace            *os.File
60	traceBuffer      *bufio.Writer
61	debugDir         string
62	timings          *os.File
63	timingBuffer     *bufio.Writer
64	frame            uint64
65	debugFrames      uint64
66	debugDropped     uint64
67	readbacks        chan readbackJob
68	readbackDone     chan struct{}
69	debugWritten     chan uint64
70	// Written only by readbackWorker; read after readbackDone.
71	debugWriteErrors  uint64
72	debugLastWriteErr string
73	fdStart, fdPeak   int
74	framePending      bool
75	waitCtx           context.Context
76	waitCancel        context.CancelFunc
77	waiters           sync.WaitGroup
78	releases          chan releaseEvent
79	wake              chan<- struct{} // optional: pinged after a release is queued
80}
81
82type retiredPipelines struct {
83	generation uint64
84	rect, list *vkdevice.Pipeline
85}
86
87type readbackJob struct {
88	frame uint64
89	image *image.NRGBA
90}
91
92const maxDebugPNGs = 16
93const readbackQueueSize = 2
94
95func captureBudget(saved, dropped uint64, pending int, queued int, limit uint64) bool {
96	return saved+dropped+uint64(pending+queued) < limit
97}
98
99// offerReadback never blocks the state owner. The worker owns the copied image.
100func offerReadback(queue chan readbackJob, job readbackJob) bool {
101	select {
102	case queue <- job:
103		return true
104	default:
105		return false
106	}
107}
108
109// readbackWorker owns debugWriteErrors until readbackDone is closed.
110func (s *Session) readbackWorker() {
111	defer close(s.readbackDone)
112	for job := range s.readbacks {
113		path := filepath.Join(s.debugDir, fmt.Sprintf("readback-%06d.png", job.frame))
114		vkdevice.SwizzleBGRA(job.image)
115		if err := writePNG(path, job.image); err != nil {
116			s.debugWriteErrors++
117			s.debugLastWriteErr = err.Error()
118			continue
119		}
120		s.debugWritten <- job.frame
121	}
122}
123
124func writePNG(path string, img image.Image) error {
125	f, err := os.Create(path)
126	if err != nil {
127		return err
128	}
129	err = png.Encode(f, img)
130	if closeErr := f.Close(); err == nil {
131		err = closeErr
132	}
133	return err
134}
135
136type releaseEvent struct {
137	id, point uint64
138	err       error
139}
140
141// traceState is entirely disabled when NEFERGUI_DEBUG_DIR is unset.
142func (s *Session) traceState(b *buffers.Buffer, event string) {
143	if s.trace == nil || b == nil {
144		return
145	}
146	_ = json.NewEncoder(s.traceBuffer).Encode(struct {
147		Time                       time.Time `json:"time"`
148		Frame, Buffer, Generation  uint64
149		State                      buffers.State
150		Event                      string
151		AcquirePoint, ReleasePoint uint64
152		WlReleased, PointSignaled  bool
153	}{time.Now(), s.frame, b.ID, b.Generation, b.State, event, b.AcquirePoint, b.ReleasePoint, b.WlReleased, b.PointSignaled})
154}
155
156// Open opens a default xdg-toplevel session.
157func Open(name string, width, height int32, transparent bool) (*Session, error) {
158	return OpenWithOptions(context.Background(), name, width, height, transparent, wayland.SurfaceOptions{})
159}
160
161// OpenWithOptions opens a session whose surface role and initial input region
162// are selected by opts; the zero value is identical to Open. Wayland startup
163// is abandoned when ctx is cancelled; ctx is not retained after it returns.
164func OpenWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts wayland.SurfaceOptions) (_ *Session, err error) {
165	w, err := wayland.ConnectWithOptions(ctx, name, width, height, transparent, opts)
166	if err != nil {
167		return nil, err
168	}
169	return OpenWindow(w, transparent)
170}
171
172// OpenWindow builds the Vulkan/DMA-BUF/explicit-sync path for a window that is
173// already configured and has completed dmabuf feedback (a Connection sibling).
174// On error the window is closed. The session owns w from here on.
175func OpenWindow(w *wayland.Window, transparent bool) (_ *Session, err error) {
176	if !w.Configured || !w.FeedbackDone {
177		_ = w.Close()
178		return nil, errors.New("session: window is not configured with complete dmabuf feedback")
179	}
180	s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)}
181	if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
182		s.debugDir = dir
183		if err = os.MkdirAll(dir, 0700); err != nil {
184			_ = w.Close()
185			return nil, err
186		}
187		s.trace, err = os.Create(filepath.Join(dir, "buffer-lifecycle.jsonl"))
188		if err != nil {
189			_ = w.Close()
190			return nil, err
191		}
192		s.timings, err = os.Create(filepath.Join(dir, "frame-timings.jsonl"))
193		if err != nil {
194			_ = s.trace.Close()
195			_ = w.Close()
196			return nil, err
197		}
198		s.traceBuffer = bufio.NewWriterSize(s.trace, 64*1024)
199		s.timingBuffer = bufio.NewWriterSize(s.timings, 64*1024)
200		s.readbacks = make(chan readbackJob, readbackQueueSize)
201		s.readbackDone = make(chan struct{})
202		s.debugWritten = make(chan uint64, maxDebugPNGs)
203		go s.readbackWorker()
204	}
205	defer func() {
206		if err != nil {
207			err = errors.Join(err, s.Close())
208		}
209	}()
210	s.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages)
211	if err != nil {
212		return nil, err
213	}
214	s.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(w.MainDevice))
215	if err != nil {
216		return nil, err
217	}
218	s.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", s.Device.Render.Minor)))
219	if err != nil {
220		return nil, err
221	}
222	if s.Acquire, err = s.Node.Create(); err != nil {
223		return nil, err
224	}
225	if s.AcquireTimeline, err = s.importTimeline(s.Acquire); err != nil {
226		return nil, err
227	}
228	s.Pool, err = buffers.New(3)
229	if err != nil {
230		return nil, err
231	}
232	if err = s.newGeneration(); err != nil {
233		return nil, err
234	}
235	return s, nil
236}
237
238func (s *Session) importTimeline(handle uint32) (*drmsyncobj.WpLinuxDrmSyncobjTimeline, error) {
239	fd, err := s.Node.ExportTimeline(handle)
240	if err != nil {
241		return nil, err
242	}
243	// WLTurbo closes the sent descriptor on successful ImportTimeline; on
244	// transport failure its ownership remains with this caller.
245	timeline, err := s.Window.SyncManager.ImportTimeline(fd)
246	if err != nil {
247		_ = unix.Close(fd)
248		return nil, err
249	}
250	return timeline, nil
251}
252
253func (s *Session) newGeneration() error {
254	width, height, err := s.Window.PhysicalSize()
255	if err != nil {
256		return err
257	}
258	mods, err := s.Device.ExportableModifiers()
259	if err != nil {
260		return err
261	}
262	selected, err := vkdevice.ChooseModifier(s.Window.Tranches, s.Transparent, mods)
263	if err != nil {
264		return err
265	}
266	pipeline, err := s.Device.NewRectPipeline(width, height)
267	if err != nil {
268		return err
269	}
270	listPipeline, err := s.Device.NewListPipeline(width, height)
271	if err != nil {
272		pipeline.Close()
273		return err
274	}
275	// A pipeline may still be referenced by submissions for the previous
276	// generation. retire closes it only after all old slots have retired.
277	if s.Pipeline != nil {
278		s.retiredPipelines = append(s.retiredPipelines, retiredPipelines{
279			generation: s.Pool.Generation - 1, rect: s.Pipeline, list: s.ListPipeline,
280		})
281	}
282	s.Pipeline = pipeline
283	s.ListPipeline = listPipeline
284	s.Width, s.Height = width, height
285	for _, b := range s.Pool.Buffers {
286		if b.Generation != s.Pool.Generation {
287			continue
288		}
289		img, e := s.Device.AllocateImage(width, height, selected, s.Window.Dmabuf)
290		if e != nil {
291			return e
292		}
293		frame, e := s.Device.NewFrame(img)
294		if e == nil && s.debugDir != "" {
295			frame.Readback, e = s.Device.NewReadback(width, height)
296		}
297		if e != nil {
298			if frame != nil {
299				frame.Close()
300			}
301			img.Close()
302			return e
303		}
304		slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}
305		slot.ReleaseHandle, e = s.Node.Create()
306		if e != nil {
307			frame.Close()
308			img.Close()
309			return e
310		}
311		slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)
312		if e != nil {
313			_ = s.Node.Destroy(slot.ReleaseHandle)
314			frame.Close()
315			img.Close()
316			return e
317		}
318		s.Slots[b.ID] = slot
319		img.Buffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
320	}
321	return nil
322}
323
324// beginReady does not change Pool ownership until the previous GPU submission
325// has completed. An unready available buffer is retried on a bounded wakeup.
326func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
327	waiting := false
328	for _, b := range pool.Buffers {
329		if b.Generation != pool.Generation || b.State != buffers.Available {
330			continue
331		}
332		slot := slots[b.ID]
333		if slot == nil {
334			return nil, nil, false, fmt.Errorf("missing slot %d", b.ID)
335		}
336		ok, err := ready(slot)
337		if err != nil {
338			return nil, nil, false, err
339		}
340		if !ok {
341			waiting = true
342			continue
343		}
344		b.State = buffers.Rendering
345		b.WlReleased, b.PointSignaled = false, false
346		return b, slot, false, nil
347	}
348	return nil, nil, waiting, nil
349}
350
351// Tick presents at most one frame. The caller drives the Wayland dispatch
352// loop; a frame callback only schedules Tick, never makes an image reusable.
353func (s *Session) Tick(rect vkdevice.Rect) (bool, error) {
354	return s.tick(&rect, nil, nil, nil)
355}
356
357// TickList presents ordered instanced quads with the same ownership gates as Tick.
358func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) (bool, error) {
359	var batches []vkdevice.Batch
360	if len(instances) > 0 {
361		batches = []vkdevice.Batch{{Count: uint32(len(instances))}}
362	}
363	return s.tick(nil, instances, uploads, batches)
364}
365
366// tickFrame lists and submits one prepared frame on the owner loop. The list
367// buffer's slices are borrowed only for this call: tick copies instances into
368// mapped GPU memory and resolve copies batches before returning, and no pending
369// GPU work or retained frame refers to them (a blocked frame is re-listed from
370// its render.Frame on retry). Release then drops borrowed image references.
371func (s *Session) tickFrame(frame render.Frame) (bool, error) {
372	defer s.list.Release()
373	instances, batches, stats := s.list.List(frame)
374	if len(stats.Skipped) != 0 {
375		return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
376	}
377	return s.tick(nil, instances, frame.Uploads, batches)
378}
379
380func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
381	started := time.Now()
382	if s.Window.Dirty {
383		w, h, err := s.Window.PhysicalSize()
384		if err != nil {
385			return false, err
386		}
387		if w != s.Width || h != s.Height {
388			if err = s.Pool.Resize(3); err != nil {
389				return false, err
390			}
391			if err = s.newGeneration(); err != nil {
392				return false, err
393			}
394		}
395		s.Window.Dirty = false
396	}
397	if err := s.retire(); err != nil {
398		return false, err
399	}
400	if !s.Window.FrameReady {
401		return false, nil
402	}
403	b, slot, waiting, err := beginReady(s.Pool, s.Slots, func(slot *Slot) (bool, error) { return slot.Frame.Ready() })
404	if err != nil {
405		return false, err
406	}
407	if b == nil {
408		s.framePending = waiting
409		return false, nil
410	}
411	s.framePending = false
412	s.traceState(b, "begin")
413	if err := s.saveReadback(slot); err != nil {
414		return false, err
415	}
416	if slot.Wait != nil && slot.WaitInFlight {
417		slot.Wait.Close()
418		slot.Wait = nil
419		slot.WaitInFlight = false
420	}
421	capture := s.wantReadback()
422	if rect != nil {
423		err = slot.Frame.Record(slot.Image, s.Pipeline, s.Width, s.Height, *rect, slot.First, capture)
424	} else {
425		err = slot.Frame.RecordList(slot.Image, s.ListPipeline, s.Width, s.Height, instances, uploads, batches, slot.First, capture)
426	}
427	if err != nil {
428		return false, err
429	}
430	if err = slot.Frame.Submit(slot.Wait); err != nil {
431		return false, err
432	}
433	// The render module keeps uploads owed until this acknowledgement.
434	s.Preparer.Submitted(uploads)
435	if slot.Wait != nil {
436		slot.WaitInFlight = true
437	}
438	// Keep the imported release-wait semaphore alive until GPU completion.
439	slot.First = false
440	fd, err := slot.Frame.Signal.Export()
441	if err != nil {
442		return false, err
443	}
444	acquire, err := s.Pool.Acquire(b)
445	if err != nil {
446		_ = unix.Close(fd)
447		return false, err
448	}
449	if err = s.Node.AcquirePoint(fd, s.Acquire, acquire); err != nil {
450		_ = unix.Close(fd)
451		return false, err
452	}
453	s.traceState(b, "acquire_point_exported")
454	release, err := s.Pool.Commit(b)
455	if err != nil {
456		return false, err
457	}
458	if err = s.Window.Present(slot.Image.Buffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
459		return false, err
460	}
461	s.traceState(b, "committed")
462	if err = s.Pool.Own(b); err != nil {
463		return false, err
464	}
465	s.traceState(b, "compositor_owns")
466	if capture {
467		slot.ReadbackFrame = s.frame
468		slot.ReadbackPending = true
469	}
470	if s.timings != nil {
471		if len(s.Slots) > 6 {
472			return false, fmt.Errorf("unbounded image generations: %d", len(s.Slots))
473		}
474		fds, _ := os.ReadDir("/proc/self/fd")
475		if len(fds) > s.fdPeak {
476			s.fdPeak = len(fds)
477		}
478		_ = json.NewEncoder(s.timingBuffer).Encode(map[string]any{"frame": s.frame, "buffer": b.ID, "generation": b.Generation, "submit_to_commit_ns": time.Since(started).Nanoseconds(), "fd_count": len(fds), "images": len(s.Slots), "atlas_pages": s.Preparer.Atlas.PagesUsed(), "pipelines": 2 * (1 + len(s.retiredPipelines)), "time": time.Now()})
479	}
480	s.startReleaseWait(slot, release)
481	s.frame++
482	return true, nil
483}
484
485func (s *Session) wantReadback() bool {
486	if s.readbacks == nil {
487		return false
488	}
489	pending := 0
490	for _, slot := range s.Slots {
491		if slot.ReadbackPending {
492			pending++
493		}
494	}
495	return captureBudget(s.debugFrames, s.debugDropped, pending, len(s.readbacks), maxDebugPNGs)
496}
497
498// saveReadback is only called for a completed GPU submission; the state owner
499// copies into owned memory and hands the PNG encoding to the bounded worker.
500func (s *Session) saveReadback(slot *Slot) error {
501	if !slot.ReadbackPending || slot.Frame.Readback == nil {
502		return nil
503	}
504	// Do not map a full-size image while the encoder cannot accept it.
505	if len(s.readbacks) == cap(s.readbacks) {
506		slot.ReadbackPending = false
507		s.debugDropped++
508		return nil
509	}
510	img, err := slot.Frame.Readback.Copy()
511	if err != nil {
512		return err
513	}
514	slot.ReadbackPending = false
515	if offerReadback(s.readbacks, readbackJob{slot.ReadbackFrame, img}) {
516		s.debugFrames++
517	} else {
518		s.debugDropped++
519	}
520	return nil
521}
522
523// startReleaseWait never mutates Pool or Vulkan state from the waiter.
524func (s *Session) startReleaseWait(slot *Slot, point uint64) {
525	if s.waitCancel == nil {
526		return
527	}
528	ctx := s.waitCtx
529	id, handle := slot.Buffer.ID, slot.ReleaseHandle
530	s.waiters.Add(1)
531	go func() {
532		defer s.waiters.Done()
533		for {
534			if ctx.Err() != nil {
535				return
536			}
537			ready, err := s.Node.WaitPoint(handle, point, 20*time.Millisecond)
538			if err != nil || ready {
539				select {
540				case s.releases <- releaseEvent{id: id, point: point, err: err}:
541					if s.wake != nil {
542						select {
543						case s.wake <- struct{}{}:
544						default:
545						}
546					}
547				case <-ctx.Done():
548				}
549				return
550			}
551		}
552	}()
553}
554
555func (s *Session) release(ev releaseEvent) error {
556	if ev.err != nil {
557		return ev.err
558	}
559	slot := s.Slots[ev.id]
560	if slot == nil {
561		return nil
562	}
563	b := slot.Buffer
564	// An older completion cannot authorize a newer commit of the same image.
565	if b.ReleasePoint != ev.point || b.State != buffers.CompositorOwned {
566		return nil
567	}
568	if err := s.Pool.Signal(b, ev.point); err != nil {
569		return err
570	}
571	s.traceState(b, "release_point_signaled")
572	if b.Generation == s.Pool.Generation {
573		fd, err := s.Node.ReleaseFence(slot.ReleaseHandle, ev.point)
574		if err != nil {
575			return err
576		}
577		wait, err := s.Device.NewBinaryFence(false)
578		if err != nil {
579			_ = unix.Close(fd)
580			return err
581		}
582		if err = wait.ImportTemporary(fd); err != nil {
583			_ = unix.Close(fd)
584			wait.Close()
585			return err
586		}
587		installed, e := installReleaseWait(slot, wait, slot.Frame.Ready)
588		if e != nil {
589			wait.Close()
590			return e
591		}
592		if !installed {
593			return nil
594		}
595		if err = s.Pool.Reuse(b, true); err != nil {
596			return err
597		}
598		s.traceState(b, "available")
599	}
600	return s.retire()
601}
602
603// installReleaseWait holds the new semaphore until the old submission is done.
604// Only the state owner calls it. On an unready fence it leaves Pool Released.
605func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bool, error)) (bool, error) {
606	if slot.Wait != nil && slot.WaitInFlight {
607		ok, err := ready()
608		if err != nil {
609			return false, err
610		}
611		if !ok {
612			slot.PendingWait = next
613			return false, nil
614		}
615	}
616	if slot.Wait != nil {
617		slot.Wait.Close()
618	}
619	slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
620	return true, nil
621}
622
623// finishPending runs only on the state-owner loop; never waits on a GPU fence.
624func (s *Session) finishPending() (bool, error) {
625	pending := false
626	for _, slot := range s.Slots {
627		if slot.PendingWait == nil {
628			continue
629		}
630		ready, err := slot.Frame.Ready()
631		if err != nil {
632			return false, err
633		}
634		if !ready {
635			pending = true
636			continue
637		}
638		if slot.Wait != nil {
639			slot.Wait.Close()
640		}
641		slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
642		if err := s.Pool.Reuse(slot.Buffer, true); err != nil {
643			return false, err
644		}
645		s.traceState(slot.Buffer, "available")
646	}
647	return pending, nil
648}
649
650func (s *Session) retire() error {
651	for _, slot := range s.Slots {
652		b := slot.Buffer
653		if !s.Pool.Retirable(b) {
654			continue
655		}
656		ready, err := slot.Frame.Ready()
657		if err != nil {
658			return err
659		}
660		if !ready {
661			continue
662		}
663		if err := s.saveReadback(slot); err != nil {
664			return err
665		}
666		slot.Frame.Close()
667		if slot.PendingWait != nil {
668			slot.PendingWait.Close()
669		}
670		if slot.Wait != nil {
671			slot.Wait.Close()
672		}
673		slot.Image.Close()
674		_ = slot.ReleaseTimeline.Destroy()
675		_ = s.Node.Destroy(slot.ReleaseHandle)
676		s.traceState(b, "retired")
677		delete(s.Slots, b.ID)
678		if err = s.Pool.Remove(b); err != nil {
679			return err
680		}
681	}
682	for i := 0; i < len(s.retiredPipelines); {
683		old := s.retiredPipelines[i]
684		live := false
685		for _, slot := range s.Slots {
686			if slot.Buffer.Generation == old.generation {
687				live = true
688				break
689			}
690		}
691		if live {
692			i++
693			continue
694		}
695		old.rect.Close()
696		old.list.Close()
697		s.retiredPipelines = append(s.retiredPipelines[:i], s.retiredPipelines[i+1:]...)
698	}
699	return nil
700}
701
702func (s *Session) Run(ctx context.Context, frames int, draw func(int) vkdevice.Rect) (int, error) {
703	return s.RunWithCommit(ctx, frames, draw, nil)
704}
705
706// RunWithCommit calls committed after each successfully committed frame.
707// The optional hook is for the example's capture request; it never gates reuse.
708func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int) vkdevice.Rect, committed func(int) error) (int, error) {
709	if draw == nil {
710		return 0, fmt.Errorf("nil draw function")
711	}
712	return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
713}
714
715// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
716// them owed until a submission, so a skipped attempt is resent by the next one.
717func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
718	if draw == nil {
719		return 0, fmt.Errorf("nil draw function")
720	}
721	return s.runWithCommit(ctx, frames, func(i int) (bool, error) {
722		frame, err := draw(i)
723		if err != nil {
724			return false, err
725		}
726		return s.tickFrame(frame)
727	}, committed)
728}
729
730// RunListWithCommit drives the list renderer using the existing frame loop.
731func (s *Session) RunListWithCommit(ctx context.Context, frames int, draw func(int) []vkdevice.Instance, committed func(int) error) (int, error) {
732	if draw == nil {
733		return 0, fmt.Errorf("nil draw function")
734	}
735	return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.TickList(draw(i), nil) }, committed)
736}
737
738func (s *Session) runWithCommit(ctx context.Context, frames int, tick func(int) (bool, error), committed func(int) error) (int, error) {
739	if frames < 0 {
740		return 0, fmt.Errorf("invalid frame count")
741	}
742	if s.waitCancel != nil {
743		return 0, fmt.Errorf("session already running")
744	}
745	s.waitCtx, s.waitCancel = context.WithCancel(ctx)
746	s.releases = make(chan releaseEvent, 128)
747	if s.debugDir != "" {
748		fds, _ := os.ReadDir("/proc/self/fd")
749		s.fdStart = len(fds)
750		s.fdPeak = s.fdStart
751	}
752	wlEvents := s.Window.StartReader()
753	count := 0
754	defer func() { s.waitCancel(); s.waiters.Wait() }()
755	for !s.Window.Closed && (frames == 0 || count < frames) {
756		if err := ctx.Err(); err != nil {
757			return count, err
758		}
759		pending, err := s.finishPending()
760		if err != nil {
761			return count, err
762		}
763		if ok, err := tick(count); err != nil {
764			return count, err
765		} else if ok {
766			count++
767			if committed != nil {
768				if err := committed(count); err != nil {
769					return count, err
770				}
771			}
772			continue
773		}
774		var retry <-chan time.Time
775		if pending || s.framePending {
776			retry = time.After(5 * time.Millisecond)
777		}
778		select {
779		case <-ctx.Done():
780			return count, ctx.Err()
781		case <-retry:
782		case ev := <-wlEvents:
783			if ev.Kind == wayland.BufferRelease {
784				if slot := s.Slots[ev.BufferID]; slot != nil {
785					if err := s.Pool.Release(slot.Buffer); err != nil {
786						return count, err
787					}
788					s.traceState(slot.Buffer, "wl_buffer.release")
789				}
790			} else if err := s.Window.Apply(ev); err != nil {
791				return count, err
792			}
793		case ev := <-s.releases:
794			if err := s.release(ev); err != nil {
795				return count, err
796			}
797		}
798	}
799	if s.Device != nil && s.Device.Validation != nil && len(s.Device.Validation.Messages()) > 0 {
800		return count, fmt.Errorf("%d Vulkan validation errors: %v", len(s.Device.Validation.Messages()), s.Device.Validation.Messages())
801	}
802	return count, nil
803}
804
805// Close detaches and destroys the explicit-sync surface, traces release points
806// for at most 200 ms, waits for our GPU, then frees every client allocation.
807// Compositor imports keep independent DMA-BUF kernel references.
808// It returns errors from writing requested debug artifacts; allocations are
809// freed regardless.
810func (s *Session) Close() (err error) {
811	if s == nil {
812		return nil
813	}
814	if s.trace != nil {
815		defer func() { err = errors.Join(err, s.traceBuffer.Flush(), s.trace.Close()) }()
816	}
817	if s.timings != nil {
818		defer func() { err = errors.Join(err, s.timingBuffer.Flush(), s.timings.Close()) }()
819	}
820	if s.readbacks != nil {
821		defer func() {
822			if s.readbacks != nil {
823				close(s.readbacks)
824				<-s.readbackDone
825			}
826		}()
827	}
828	if s.Window != nil && s.Window.Surface != nil {
829		// A null-buffer commit is a protocol error on an ext-session-lock
830		// surface; its destruction hides it instead.
831		if !s.Window.IsLock() {
832			_ = s.Window.Surface.Attach(nil, 0, 0)
833			_ = s.Window.Surface.Commit()
834		}
835		_ = s.Window.DestroySyncSurface()
836	}
837	// Drain detach/release points for tracing, not for freeing allocations. The
838	// reader and waiters continue posting events, but the loop owns all state.
839	if s.waitCancel != nil && s.Pool != nil {
840		deadline := time.NewTimer(200 * time.Millisecond)
841		defer deadline.Stop()
842		for {
843			busy := false
844			for _, slot := range s.Slots {
845				if slot.Buffer.State != buffers.Available && slot.Buffer.State != buffers.Released {
846					busy = true
847					break
848				}
849			}
850			if !busy {
851				break
852			}
853			select {
854			case ev := <-s.releases:
855				_ = s.release(ev)
856			case ev := <-s.Window.ReaderEvents(): // nil on a shared connection
857				if ev.Kind == wayland.BufferRelease {
858					if slot := s.Slots[ev.BufferID]; slot != nil {
859						_ = s.Pool.Release(slot.Buffer)
860					}
861				} else if ev.Kind != wayland.TransportError {
862					_ = s.Window.Apply(ev)
863				}
864			case <-deadline.C:
865				goto stopWaiters
866			}
867		}
868	}
869stopWaiters:
870	if s.waitCancel != nil {
871		s.waitCancel()
872		s.waiters.Wait()
873		s.waitCancel = nil
874	}
875	// The client GPU's completion is independent from compositor release.
876	if s.Device != nil && s.Device.Logical != 0 && s.Device.Dispatch != nil {
877		_ = s.Device.Dispatch.DeviceWaitIdle(s.Device.Logical)
878	}
879	if s.Pool != nil {
880		_ = s.retire()
881	}
882	for _, slot := range s.Slots {
883		// GPU work must finish before destroying a semaphore it waits on.
884		if slot.ReadbackPending {
885			_ = s.saveReadback(slot)
886		}
887		if slot.Frame != nil {
888			slot.Frame.Close()
889		}
890		if slot.Wait != nil {
891			slot.Wait.Close()
892		}
893		if slot.PendingWait != nil {
894			slot.PendingWait.Close()
895		}
896		if slot.ReleaseTimeline != nil {
897			_ = slot.ReleaseTimeline.Destroy()
898		}
899		// The exported DMA-BUF remains alive in the compositor independently.
900		if slot.Image != nil {
901			slot.Image.Close()
902		}
903		if s.Node != nil && slot.ReleaseHandle != 0 {
904			_ = s.Node.Destroy(slot.ReleaseHandle)
905		}
906	}
907	if s.AcquireTimeline != nil {
908		_ = s.AcquireTimeline.Destroy()
909	}
910	if s.Window != nil {
911		_ = s.Window.Close()
912	}
913	if s.Node != nil {
914		if s.Acquire != 0 {
915			_ = s.Node.Destroy(s.Acquire)
916		}
917		_ = s.Node.Close()
918	}
919	for _, old := range s.retiredPipelines {
920		old.rect.Close()
921		old.list.Close()
922	}
923	s.retiredPipelines = nil
924	if s.Pipeline != nil {
925		s.Pipeline.Close()
926	}
927	if s.ListPipeline != nil {
928		s.ListPipeline.Close()
929	}
930	if s.Device != nil {
931		s.Device.Close()
932	}
933	if s.debugDir != "" {
934		// Wait for the worker before reporting completed PNGs.
935		if s.readbacks != nil {
936			close(s.readbacks)
937			<-s.readbackDone
938			s.readbacks = nil
939		}
940		runtime.GC()
941		fds, _ := os.ReadDir("/proc/self/fd")
942		data, e := json.MarshalIndent(map[string]any{"fd_start": s.fdStart, "fd_peak": s.fdPeak, "fd_after": len(fds), "frames": s.frame, "images_after": 0, "debug_png_count": len(s.debugWritten), "debug_png_dropped": s.debugDropped, "debug_png_write_errors": s.debugWriteErrors, "debug_png_last_error": s.debugLastWriteErr}, "", "  ")
943		if e == nil {
944			e = os.WriteFile(filepath.Join(s.debugDir, "resource-summary.json"), append(data, '\n'), 0600)
945		}
946		if e != nil {
947			err = errors.Join(err, fmt.Errorf("session: resource summary: %w", e))
948		}
949	}
950	return err
951}

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