detached c0e00f5

fix(app): propagate lifecycle release failures

commit
c0e00f5d9c4e97cfabb365418a0462811b79b291
author
brice · · 2026-07-26 01:32 UTC
parent
1c282c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +103 -73
M internal/app/run.go +8 -4
@@ -419,6 +419,10 @@ type lifecycleOwnership interface {
419419 Release() error
420420 }
421421
422+func joinLifecycleReleaseError(retErr *error, owner lifecycleOwnership) {
423+ *retErr = errors.Join(*retErr, owner.Release())
424+}
425+
422426 type lifecycleStartupDeps struct {
423427 ensurePrivate func(string) error
424428 acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)
@@ -1213,13 +1217,13 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
12131217
12141218 // runList prints the daemon's session listing. With no daemon running, it
12151219 // falls back to the persisted stopped-session records.
1216-func runList(ctx context.Context) error {
1220+func runList(ctx context.Context) (retErr error) {
12171221 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
12181222 if err != nil {
12191223 return fmt.Errorf("vev: waiting for durable session state: %w", err)
12201224 }
12211225 if owner != nil {
1222- defer func() { _ = owner.Release() }()
1226+ defer joinLifecycleReleaseError(&retErr, owner)
12231227 records, loadErr := persist.LoadReadOnly(platform.StateDir())
12241228 if loadErr != nil {
12251229 return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
@@ -1340,13 +1344,13 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
13401344 }
13411345
13421346 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1343-func runKill(ctx context.Context, name string, all, daemon bool) error {
1347+func runKill(ctx context.Context, name string, all, daemon bool) (retErr error) {
13441348 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
13451349 if err != nil {
13461350 return fmt.Errorf("vev: waiting for durable session state: %w", err)
13471351 }
13481352 if owner != nil {
1349- defer func() { _ = owner.Release() }()
1353+ defer joinLifecycleReleaseError(&retErr, owner)
13501354 if name != "" && !all && !daemon {
13511355 if err := runOfflineNamedKill(ctx, name); err != nil {
13521356 return err
@@ -419,6 +419,10 @@ type lifecycleOwnership interface {
419 Release() error419 Release() error
420 }420 }
421 421
422+func joinLifecycleReleaseError(retErr *error, owner lifecycleOwnership) {
423+ *retErr = errors.Join(*retErr, owner.Release())
424+}
425+
422 type lifecycleStartupDeps struct {426 type lifecycleStartupDeps struct {
423 ensurePrivate func(string) error427 ensurePrivate func(string) error
424 acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)428 acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)
@@ -1213,13 +1217,13 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
1213 1217
1214 // runList prints the daemon's session listing. With no daemon running, it1218 // runList prints the daemon's session listing. With no daemon running, it
1215 // falls back to the persisted stopped-session records.1219 // falls back to the persisted stopped-session records.
1216-func runList(ctx context.Context) error {1220+func runList(ctx context.Context) (retErr error) {
1217 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)1221 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
1218 if err != nil {1222 if err != nil {
1219 return fmt.Errorf("vev: waiting for durable session state: %w", err)1223 return fmt.Errorf("vev: waiting for durable session state: %w", err)
1220 }1224 }
1221 if owner != nil {1225 if owner != nil {
1222- defer func() { _ = owner.Release() }()1226+ defer joinLifecycleReleaseError(&retErr, owner)
1223 records, loadErr := persist.LoadReadOnly(platform.StateDir())1227 records, loadErr := persist.LoadReadOnly(platform.StateDir())
1224 if loadErr != nil {1228 if loadErr != nil {
1225 return fmt.Errorf("vev: reading stored sessions: %w", loadErr)1229 return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
@@ -1340,13 +1344,13 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1340 }1344 }
1341 1345
1342 // runKill asks the daemon to terminate a named session, every session, or the daemon.1346 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1343-func runKill(ctx context.Context, name string, all, daemon bool) error {1347+func runKill(ctx context.Context, name string, all, daemon bool) (retErr error) {
1344 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)1348 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
1345 if err != nil {1349 if err != nil {
1346 return fmt.Errorf("vev: waiting for durable session state: %w", err)1350 return fmt.Errorf("vev: waiting for durable session state: %w", err)
1347 }1351 }
1348 if owner != nil {1352 if owner != nil {
1349- defer func() { _ = owner.Release() }()1353+ defer joinLifecycleReleaseError(&retErr, owner)
1350 if name != "" && !all && !daemon {1354 if name != "" && !all && !daemon {
1351 if err := runOfflineNamedKill(ctx, name); err != nil {1355 if err := runOfflineNamedKill(ctx, name); err != nil {
1352 return err1356 return err
M internal/app/run_test.go +86 -0
@@ -33,6 +33,92 @@ type fakeLifecycleOwnership struct {
3333
3434 func (o fakeLifecycleOwnership) Release() error { return o.release() }
3535
36+type fakeLifecycleProbe struct {
37+ owner lifecycleOwnership
38+ err error
39+}
40+
41+func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
42+ return p.owner, p.err
43+}
44+
45+func TestJoinLifecycleReleaseError(t *testing.T) {
46+ operationErr := errors.New("operation failed")
47+ releaseErr := errors.New("release failed")
48+
49+ tests := []struct {
50+ name string
51+ operationErr error
52+ releaseErr error
53+ wantErrors []error
54+ }{
55+ {
56+ name: "successful operation preserves release failure",
57+ releaseErr: releaseErr,
58+ wantErrors: []error{releaseErr},
59+ },
60+ {
61+ name: "operation and release failures are joined",
62+ operationErr: operationErr,
63+ releaseErr: releaseErr,
64+ wantErrors: []error{operationErr, releaseErr},
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ got := tt.operationErr
71+ joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
72+ return tt.releaseErr
73+ }})
74+
75+ for _, wantErr := range tt.wantErrors {
76+ require.ErrorIs(t, got, wantErr)
77+ }
78+ })
79+ }
80+}
81+
82+func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
83+ releaseErr := errors.New("release failed")
84+
85+ tests := []struct {
86+ name string
87+ run func(context.Context) error
88+ wantOperationErr string
89+ }{
90+ {
91+ name: "list joins release failure to success",
92+ run: runList,
93+ },
94+ {
95+ name: "kill joins release failure to operation failure",
96+ run: func(ctx context.Context) error {
97+ return runKill(ctx, "", false, false)
98+ },
99+ wantOperationErr: "vev: no daemon running",
100+ },
101+ }
102+
103+ originalProbe := daemonLifecycleProbe
104+ t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
108+ t.Setenv("XDG_STATE_HOME", t.TempDir())
109+ daemonLifecycleProbe = fakeLifecycleProbe{
110+ owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
111+ }
112+
113+ err := tt.run(context.Background())
114+ require.ErrorIs(t, err, releaseErr)
115+ if tt.wantOperationErr != "" {
116+ require.ErrorContains(t, err, tt.wantOperationErr)
117+ }
118+ })
119+ }
120+}
121+
36122 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
37123 var events []string
38124 _, _, err := constructDaemonBeforeSocketPublication(
@@ -33,6 +33,92 @@ type fakeLifecycleOwnership struct {
33 33
34 func (o fakeLifecycleOwnership) Release() error { return o.release() }34 func (o fakeLifecycleOwnership) Release() error { return o.release() }
35 35
36+type fakeLifecycleProbe struct {
37+ owner lifecycleOwnership
38+ err error
39+}
40+
41+func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
42+ return p.owner, p.err
43+}
44+
45+func TestJoinLifecycleReleaseError(t *testing.T) {
46+ operationErr := errors.New("operation failed")
47+ releaseErr := errors.New("release failed")
48+
49+ tests := []struct {
50+ name string
51+ operationErr error
52+ releaseErr error
53+ wantErrors []error
54+ }{
55+ {
56+ name: "successful operation preserves release failure",
57+ releaseErr: releaseErr,
58+ wantErrors: []error{releaseErr},
59+ },
60+ {
61+ name: "operation and release failures are joined",
62+ operationErr: operationErr,
63+ releaseErr: releaseErr,
64+ wantErrors: []error{operationErr, releaseErr},
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ got := tt.operationErr
71+ joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
72+ return tt.releaseErr
73+ }})
74+
75+ for _, wantErr := range tt.wantErrors {
76+ require.ErrorIs(t, got, wantErr)
77+ }
78+ })
79+ }
80+}
81+
82+func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
83+ releaseErr := errors.New("release failed")
84+
85+ tests := []struct {
86+ name string
87+ run func(context.Context) error
88+ wantOperationErr string
89+ }{
90+ {
91+ name: "list joins release failure to success",
92+ run: runList,
93+ },
94+ {
95+ name: "kill joins release failure to operation failure",
96+ run: func(ctx context.Context) error {
97+ return runKill(ctx, "", false, false)
98+ },
99+ wantOperationErr: "vev: no daemon running",
100+ },
101+ }
102+
103+ originalProbe := daemonLifecycleProbe
104+ t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
105+ for _, tt := range tests {
106+ t.Run(tt.name, func(t *testing.T) {
107+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
108+ t.Setenv("XDG_STATE_HOME", t.TempDir())
109+ daemonLifecycleProbe = fakeLifecycleProbe{
110+ owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
111+ }
112+
113+ err := tt.run(context.Background())
114+ require.ErrorIs(t, err, releaseErr)
115+ if tt.wantOperationErr != "" {
116+ require.ErrorContains(t, err, tt.wantOperationErr)
117+ }
118+ })
119+ }
120+}
121+
36 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {122 func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
37 var events []string123 var events []string
38 _, _, err := constructDaemonBeforeSocketPublication(124 _, _, err := constructDaemonBeforeSocketPublication(
M internal/app/spawn.go +4 -4
@@ -41,12 +41,12 @@ type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
4141 type spawnFunc func() error
4242
4343 type lifecycleProbe interface {
44- TryAcquire(string) (*lifecycle.Owner, error)
44+ TryAcquire(string) (lifecycleOwnership, error)
4545 }
4646
4747 type osLifecycleProbe struct{}
4848
49-func (osLifecycleProbe) TryAcquire(runtimeDir string) (*lifecycle.Owner, error) {
49+func (osLifecycleProbe) TryAcquire(runtimeDir string) (lifecycleOwnership, error) {
5050 return lifecycle.TryAcquire(runtimeDir)
5151 }
5252
@@ -67,7 +67,7 @@ var defaultBackoff = backoffConfig{
6767 total: 5 * time.Second,
6868 }
6969
70-func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (*lifecycle.Owner, error) {
70+func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
7171 deadline := time.Now().Add(cfg.total)
7272 backoff := cfg.initial
7373 for {
@@ -93,7 +93,7 @@ func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg ba
9393 }
9494 }
9595
96-func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, *lifecycle.Owner, error) {
96+func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
9797 deadline := time.Now().Add(cfg.total)
9898 backoff := cfg.initial
9999 for {
@@ -41,12 +41,12 @@ type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
41 type spawnFunc func() error41 type spawnFunc func() error
42 42
43 type lifecycleProbe interface {43 type lifecycleProbe interface {
44- TryAcquire(string) (*lifecycle.Owner, error)44+ TryAcquire(string) (lifecycleOwnership, error)
45 }45 }
46 46
47 type osLifecycleProbe struct{}47 type osLifecycleProbe struct{}
48 48
49-func (osLifecycleProbe) TryAcquire(runtimeDir string) (*lifecycle.Owner, error) {49+func (osLifecycleProbe) TryAcquire(runtimeDir string) (lifecycleOwnership, error) {
50 return lifecycle.TryAcquire(runtimeDir)50 return lifecycle.TryAcquire(runtimeDir)
51 }51 }
52 52
@@ -67,7 +67,7 @@ var defaultBackoff = backoffConfig{
67 total: 5 * time.Second,67 total: 5 * time.Second,
68 }68 }
69 69
70-func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (*lifecycle.Owner, error) {70+func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
71 deadline := time.Now().Add(cfg.total)71 deadline := time.Now().Add(cfg.total)
72 backoff := cfg.initial72 backoff := cfg.initial
73 for {73 for {
@@ -93,7 +93,7 @@ func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg ba
93 }93 }
94 }94 }
95 95
96-func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, *lifecycle.Owner, error) {96+func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
97 deadline := time.Now().Add(cfg.total)97 deadline := time.Now().Add(cfg.total)
98 backoff := cfg.initial98 backoff := cfg.initial
99 for {99 for {
M internal/usecase/daemon/session.go +0 -1
@@ -96,7 +96,6 @@ type session struct {
9696 snapshotPublicationContext context.Context
9797 snapshotPublicationCancel context.CancelFunc
9898 snapshotQuarantined bool
99- snapshotQuarantineEpoch uint64
10099 // snapshotChunkCache retains encoded sealed history only for this named
101100 // session. snapshotMu owns it so cache touches never contend with pane
102101 // state or mutate bytes retained by queued/in-flight publications.
@@ -96,7 +96,6 @@ type session struct {
96 snapshotPublicationContext context.Context96 snapshotPublicationContext context.Context
97 snapshotPublicationCancel context.CancelFunc97 snapshotPublicationCancel context.CancelFunc
98 snapshotQuarantined bool98 snapshotQuarantined bool
99- snapshotQuarantineEpoch uint64
100 // snapshotChunkCache retains encoded sealed history only for this named99 // snapshotChunkCache retains encoded sealed history only for this named
101 // session. snapshotMu owns it so cache touches never contend with pane100 // session. snapshotMu owns it so cache touches never contend with pane
102 // state or mutate bytes retained by queued/in-flight publications.101 // state or mutate bytes retained by queued/in-flight publications.
M internal/usecase/daemon/snapshot_coordinator.go +5 -64
@@ -1,23 +1,11 @@
11 package daemon
22
3-import (
4- "context"
5- "time"
6-)
7-
8-// snapshotCoordinatorQuarantine identifies one stop request. A later teardown
9-// can supersede it, in which case only that later owner may resume publication.
10-type snapshotCoordinatorQuarantine struct {
11- done <-chan struct{}
12- epoch uint64
13-}
14-
153 // quarantineSnapshotCoordinator prevents a deleted session from starting any
164 // further publication. The returned join channel is closed after an already
175 // started repository call returns; callers must wait without daemon or session
186 // locks before deleting repository state.
197 func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
20- return quarantineSnapshotCoordinatorWithEpoch(sess).done
8+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
219 }
2210
2311 // quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
@@ -26,22 +14,16 @@ func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
2614 // quarantined, and the worker eventually discards it after its blocked call
2715 // returns.
2816 func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
29- return quarantineSnapshotCoordinatorWithOptions(sess, true).done
17+ return quarantineSnapshotCoordinatorWithOptions(sess, true)
3018 }
3119
32-func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {
33- return quarantineSnapshotCoordinatorWithOptions(sess, false)
34-}
35-
36-func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
20+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) <-chan struct{} {
3721 if sess == nil {
3822 done := make(chan struct{})
3923 close(done)
40- return snapshotCoordinatorQuarantine{done: done}
24+ return done
4125 }
4226 sess.snapshotMu.Lock()
43- sess.snapshotQuarantineEpoch++
44- epoch := sess.snapshotQuarantineEpoch
4527 sess.snapshotQuarantined = true
4628 sess.snapEligible.Store(false)
4729 if sess.snapshotPublicationCancel != nil {
@@ -65,48 +47,7 @@ func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture
6547 }
6648 sess.signalSnapshotChangedLocked()
6749 sess.snapshotMu.Unlock()
68- return snapshotCoordinatorQuarantine{done: done, epoch: epoch}
69-}
70-
71-// resumeSnapshotCoordinatorForNewIdentity starts a fresh repository lineage
72-// only after rename committed its new in-memory name. The epoch prevents a
73-// concurrent teardown from being undone by a late rename completion.
74-func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotCoordinatorQuarantine) bool {
75- if sess == nil {
76- return false
77- }
78- sess.snapshotMu.Lock()
79- if !sess.snapshotQuarantined || sess.snapshotQuarantineEpoch != quarantine.epoch {
80- sess.snapshotMu.Unlock()
81- return false
82- }
83- if sess.snapshotPublicationCancel != nil {
84- sess.snapshotPublicationCancel()
85- }
86- sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
87- sess.snapshotQuarantined = false
88- // A repository name owns its own generation stream. Reset all scheduler
89- // state while eligibility remains false, then atomically make generation 1
90- // dirty and wake the scheduler.
91- sess.snapshotGeneration = 1
92- sess.snapshotPublishedGeneration = 0
93- sess.snapshotPublishedCheckpoint = nil
94- sess.snapshotPublishedMutationRevision = 0
95- sess.snapshotForcedGeneration = 0
96- sess.snapshotNextEligibleAt = time.Time{}
97- sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
98- sess.snapDirty.Store(true)
99- sess.snapEligible.Store(true)
100- sess.signalSnapshotChangedLocked()
101- wake := sess.snapshotWake
102- sess.snapshotMu.Unlock()
103- if wake != nil {
104- select {
105- case wake <- struct{}{}:
106- default:
107- }
108- }
109- return true
50+ return done
11051 }
11152
11253 func markSnapshotDirty(sess *session) {
@@ -1,23 +1,11 @@
1 package daemon1 package daemon
2 2
3-import (
4- "context"
5- "time"
6-)
7-
8-// snapshotCoordinatorQuarantine identifies one stop request. A later teardown
9-// can supersede it, in which case only that later owner may resume publication.
10-type snapshotCoordinatorQuarantine struct {
11- done <-chan struct{}
12- epoch uint64
13-}
14-
15 // quarantineSnapshotCoordinator prevents a deleted session from starting any3 // quarantineSnapshotCoordinator prevents a deleted session from starting any
16 // further publication. The returned join channel is closed after an already4 // further publication. The returned join channel is closed after an already
17 // started repository call returns; callers must wait without daemon or session5 // started repository call returns; callers must wait without daemon or session
18 // locks before deleting repository state.6 // locks before deleting repository state.
19 func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {7 func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
20- return quarantineSnapshotCoordinatorWithEpoch(sess).done8+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
21 }9 }
22 10
23 // quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced11 // quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
@@ -26,22 +14,16 @@ func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
26 // quarantined, and the worker eventually discards it after its blocked call14 // quarantined, and the worker eventually discards it after its blocked call
27 // returns.15 // returns.
28 func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {16 func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
29- return quarantineSnapshotCoordinatorWithOptions(sess, true).done17+ return quarantineSnapshotCoordinatorWithOptions(sess, true)
30 }18 }
31 19
32-func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {20+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) <-chan struct{} {
33- return quarantineSnapshotCoordinatorWithOptions(sess, false)
34-}
35-
36-func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
37 if sess == nil {21 if sess == nil {
38 done := make(chan struct{})22 done := make(chan struct{})
39 close(done)23 close(done)
40- return snapshotCoordinatorQuarantine{done: done}24+ return done
41 }25 }
42 sess.snapshotMu.Lock()26 sess.snapshotMu.Lock()
43- sess.snapshotQuarantineEpoch++
44- epoch := sess.snapshotQuarantineEpoch
45 sess.snapshotQuarantined = true27 sess.snapshotQuarantined = true
46 sess.snapEligible.Store(false)28 sess.snapEligible.Store(false)
47 if sess.snapshotPublicationCancel != nil {29 if sess.snapshotPublicationCancel != nil {
@@ -65,48 +47,7 @@ func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture
65 }47 }
66 sess.signalSnapshotChangedLocked()48 sess.signalSnapshotChangedLocked()
67 sess.snapshotMu.Unlock()49 sess.snapshotMu.Unlock()
68- return snapshotCoordinatorQuarantine{done: done, epoch: epoch}50+ return done
69-}
70-
71-// resumeSnapshotCoordinatorForNewIdentity starts a fresh repository lineage
72-// only after rename committed its new in-memory name. The epoch prevents a
73-// concurrent teardown from being undone by a late rename completion.
74-func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotCoordinatorQuarantine) bool {
75- if sess == nil {
76- return false
77- }
78- sess.snapshotMu.Lock()
79- if !sess.snapshotQuarantined || sess.snapshotQuarantineEpoch != quarantine.epoch {
80- sess.snapshotMu.Unlock()
81- return false
82- }
83- if sess.snapshotPublicationCancel != nil {
84- sess.snapshotPublicationCancel()
85- }
86- sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
87- sess.snapshotQuarantined = false
88- // A repository name owns its own generation stream. Reset all scheduler
89- // state while eligibility remains false, then atomically make generation 1
90- // dirty and wake the scheduler.
91- sess.snapshotGeneration = 1
92- sess.snapshotPublishedGeneration = 0
93- sess.snapshotPublishedCheckpoint = nil
94- sess.snapshotPublishedMutationRevision = 0
95- sess.snapshotForcedGeneration = 0
96- sess.snapshotNextEligibleAt = time.Time{}
97- sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
98- sess.snapDirty.Store(true)
99- sess.snapEligible.Store(true)
100- sess.signalSnapshotChangedLocked()
101- wake := sess.snapshotWake
102- sess.snapshotMu.Unlock()
103- if wake != nil {
104- select {
105- case wake <- struct{}{}:
106- default:
107- }
108- }
109- return true
110 }51 }
111 52
112 func markSnapshotDirty(sess *session) {53 func markSnapshotDirty(sess *session) {

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 12

Tags 11

↑↓ moveenter openesc close