detached 808c829

fix(daemon): join restoration before releasing lifecycle ownership

startup restoration repairs HEADs, promotes fallbacks and replaces catalogue records, but was joined only under the bounded shutdown deadline, so the flock could be released while it still mutated durable state. It is now joined unconditionally by WaitDurableWriters. Also fixes a misleading degraded attach message, a false "loading persisted sessions failed" warning on persistence-free daemons, and a zero incarnation in a reconciliation log field.
commit
808c8298adc4ec4b631e476aed4cb97010935dad
author
brice · · 2026-07-26 04:54 UTC
parent
48ce437
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +123 -12
M internal/usecase/daemon/daemon.go +25 -9
@@ -131,10 +131,15 @@ type Daemon struct {
131131 maintenance maintenanceDependencies
132132 maintenanceWorkerCancel context.CancelFunc
133133 maintenanceWorkerDone chan struct{}
134- legacySnapshots ports.LegacySnapshotSource
135- snapsEnabled bool
136- noticeStore ports.NoticeStore
137- snapshotJobs chan *snapshotCapture
134+ // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
135+ // restoration repairs HEADs, promotes fallbacks, and replaces catalogue
136+ // records, so it is a durable writer and is guarded by snapshotWorkerMu with
137+ // the other two.
138+ restoreWorkerDone chan struct{}
139+ legacySnapshots ports.LegacySnapshotSource
140+ snapsEnabled bool
141+ noticeStore ports.NoticeStore
142+ snapshotJobs chan *snapshotCapture
138143 // snapshotWake wakes the repository scheduler when a session becomes dirty
139144 // or an attempt completes. It is never closed and producers only send
140145 // non-blockingly.
@@ -483,7 +488,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
483488 d.restoreProcessAllowlist.Store(&allow)
484489 }
485490 records := d.catalogueRecords
486- if !d.catalogueRecordsProvided {
491+ // Only attempt the load when persistence is actually configured
492+ // (d.persistEnabled tracks the same store-is-non-nil condition
493+ // persist.Persister uses to distinguish "no store" from "load failed").
494+ // Without this gate, the default no-persistence Daemon (persist.New(nil))
495+ // would log a false-alarm "loading persisted sessions failed" warning on
496+ // every construction: nothing failed, persistence simply isn't enabled.
497+ if !d.catalogueRecordsProvided && d.persistEnabled {
487498 var err error
488499 records, err = d.persist.LoadAll()
489500 if err != nil {
@@ -541,9 +552,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
541552 d.sessWg.Go(func() {
542553 d.snapshotRepositorySaver(d.serveCtx)
543554 })
544- d.sessWg.Go(func() {
545- d.restoreSnapshots(d.serveCtx)
546- })
555+ d.startSnapshotRestoration()
547556 } else {
548557 d.closeRestoreDone()
549558 }
@@ -992,7 +1001,14 @@ func (d *Daemon) waitForTargetRestore(name string) error {
9921001 case <-shutdown:
9931002 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
9941003 }
995- case runtimeDegraded, runtimeDeleting, runtimeHealthy:
1004+ case runtimeHealthy:
1005+ // The catalogue record is healthy; only its runtime registration was
1006+ // skipped (shutdown, or a live session claimed the name first). Saying
1007+ // "degraded" here would send the operator to durable recovery for a
1008+ // session whose durable state is intact.
1009+ d.mu.Unlock()
1010+ return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name}
1011+ case runtimeDegraded, runtimeDeleting:
9961012 d.mu.Unlock()
9971013 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
9981014 default:
@@ -131,10 +131,15 @@ type Daemon struct {
131 maintenance maintenanceDependencies131 maintenance maintenanceDependencies
132 maintenanceWorkerCancel context.CancelFunc132 maintenanceWorkerCancel context.CancelFunc
133 maintenanceWorkerDone chan struct{}133 maintenanceWorkerDone chan struct{}
134- legacySnapshots ports.LegacySnapshotSource134+ // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
135- snapsEnabled bool135+ // restoration repairs HEADs, promotes fallbacks, and replaces catalogue
136- noticeStore ports.NoticeStore136+ // records, so it is a durable writer and is guarded by snapshotWorkerMu with
137- snapshotJobs chan *snapshotCapture137+ // the other two.
138+ restoreWorkerDone chan struct{}
139+ legacySnapshots ports.LegacySnapshotSource
140+ snapsEnabled bool
141+ noticeStore ports.NoticeStore
142+ snapshotJobs chan *snapshotCapture
138 // snapshotWake wakes the repository scheduler when a session becomes dirty143 // snapshotWake wakes the repository scheduler when a session becomes dirty
139 // or an attempt completes. It is never closed and producers only send144 // or an attempt completes. It is never closed and producers only send
140 // non-blockingly.145 // non-blockingly.
@@ -483,7 +488,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
483 d.restoreProcessAllowlist.Store(&allow)488 d.restoreProcessAllowlist.Store(&allow)
484 }489 }
485 records := d.catalogueRecords490 records := d.catalogueRecords
486- if !d.catalogueRecordsProvided {491+ // Only attempt the load when persistence is actually configured
492+ // (d.persistEnabled tracks the same store-is-non-nil condition
493+ // persist.Persister uses to distinguish "no store" from "load failed").
494+ // Without this gate, the default no-persistence Daemon (persist.New(nil))
495+ // would log a false-alarm "loading persisted sessions failed" warning on
496+ // every construction: nothing failed, persistence simply isn't enabled.
497+ if !d.catalogueRecordsProvided && d.persistEnabled {
487 var err error498 var err error
488 records, err = d.persist.LoadAll()499 records, err = d.persist.LoadAll()
489 if err != nil {500 if err != nil {
@@ -541,9 +552,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
541 d.sessWg.Go(func() {552 d.sessWg.Go(func() {
542 d.snapshotRepositorySaver(d.serveCtx)553 d.snapshotRepositorySaver(d.serveCtx)
543 })554 })
544- d.sessWg.Go(func() {555+ d.startSnapshotRestoration()
545- d.restoreSnapshots(d.serveCtx)
546- })
547 } else {556 } else {
548 d.closeRestoreDone()557 d.closeRestoreDone()
549 }558 }
@@ -992,7 +1001,14 @@ func (d *Daemon) waitForTargetRestore(name string) error {
992 case <-shutdown:1001 case <-shutdown:
993 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}1002 return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
994 }1003 }
995- case runtimeDegraded, runtimeDeleting, runtimeHealthy:1004+ case runtimeHealthy:
1005+ // The catalogue record is healthy; only its runtime registration was
1006+ // skipped (shutdown, or a live session claimed the name first). Saying
1007+ // "degraded" here would send the operator to durable recovery for a
1008+ // session whose durable state is intact.
1009+ d.mu.Unlock()
1010+ return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name}
1011+ case runtimeDegraded, runtimeDeleting:
996 d.mu.Unlock()1012 d.mu.Unlock()
997 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}1013 return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
998 default:1014 default:
M internal/usecase/daemon/durable_recovery_test.go +26 -0
@@ -510,6 +510,32 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
510510 }
511511 }
512512
513+// A healthy catalogue record whose runtime registration was skipped is not
514+// degraded durable state, and must not send the operator to durable recovery.
515+func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
516+ record := durableRecoveryRecord(0)
517+ tests := []struct {
518+ name string
519+ state runtimeRecoveryState
520+ message string
521+ }{
522+ {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name},
523+ {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name},
524+ {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name},
525+ }
526+ for _, tt := range tests {
527+ t.Run(tt.name, func(t *testing.T) {
528+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
529+ d.setStoppedRecovery(record, tt.state)
530+
531+ var protocolError *protoErr
532+ require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError)
533+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
534+ require.Equal(t, tt.message, protocolError.Error())
535+ })
536+ }
537+}
538+
513539 func TestAttachRejectsDegraded(t *testing.T) {
514540 record := durableRecoveryRecord(0)
515541 record.RecoveryState = domain.RecoveryDegraded
@@ -510,6 +510,32 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
510 }510 }
511 }511 }
512 512
513+// A healthy catalogue record whose runtime registration was skipped is not
514+// degraded durable state, and must not send the operator to durable recovery.
515+func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
516+ record := durableRecoveryRecord(0)
517+ tests := []struct {
518+ name string
519+ state runtimeRecoveryState
520+ message string
521+ }{
522+ {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name},
523+ {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name},
524+ {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name},
525+ }
526+ for _, tt := range tests {
527+ t.Run(tt.name, func(t *testing.T) {
528+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
529+ d.setStoppedRecovery(record, tt.state)
530+
531+ var protocolError *protoErr
532+ require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError)
533+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
534+ require.Equal(t, tt.message, protocolError.Error())
535+ })
536+ }
537+}
538+
513 func TestAttachRejectsDegraded(t *testing.T) {539 func TestAttachRejectsDegraded(t *testing.T) {
514 record := durableRecoveryRecord(0)540 record := durableRecoveryRecord(0)
515 record.RecoveryState = domain.RecoveryDegraded541 record.RecoveryState = domain.RecoveryDegraded
M internal/usecase/daemon/maintenance.go +10 -3
@@ -119,13 +119,20 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
119119 if ok && !decision.RetentionResolved {
120120 deps.repairUncertain[record.IncarnationID] = struct{}{}
121121 }
122- d.log.Info("durable_reconciliation_decision",
123- "incarnation", record.IncarnationID.String(),
122+ // The catalogue lookup can miss (e.g. an unknown-incarnation finding for a
123+ // name the catalogue never held); logging record.IncarnationID.String() in
124+ // that case would emit a misleading all-zero incarnation, so the field is
125+ // omitted rather than logged as a fabricated identity.
126+ args := []any{
124127 "session", decision.Name,
125128 "action", decision.Kind,
126129 "reason_code", decision.ReasonCode,
127130 "cursor", next.DirectoryCookie,
128- )
131+ }
132+ if ok {
133+ args = append([]any{"incarnation", record.IncarnationID.String()}, args...)
134+ }
135+ d.log.Info("durable_reconciliation_decision", args...)
129136 }
130137 d.log.Info("durable_reconciliation_tick", "action", "scan", "cursor", next.DirectoryCookie, "done", next.DirectoryCookie == 0, "err", nil)
131138 if next.DirectoryCookie != 0 {
@@ -119,13 +119,20 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
119 if ok && !decision.RetentionResolved {119 if ok && !decision.RetentionResolved {
120 deps.repairUncertain[record.IncarnationID] = struct{}{}120 deps.repairUncertain[record.IncarnationID] = struct{}{}
121 }121 }
122- d.log.Info("durable_reconciliation_decision",122+ // The catalogue lookup can miss (e.g. an unknown-incarnation finding for a
123- "incarnation", record.IncarnationID.String(),123+ // name the catalogue never held); logging record.IncarnationID.String() in
124+ // that case would emit a misleading all-zero incarnation, so the field is
125+ // omitted rather than logged as a fabricated identity.
126+ args := []any{
124 "session", decision.Name,127 "session", decision.Name,
125 "action", decision.Kind,128 "action", decision.Kind,
126 "reason_code", decision.ReasonCode,129 "reason_code", decision.ReasonCode,
127 "cursor", next.DirectoryCookie,130 "cursor", next.DirectoryCookie,
128- )131+ }
132+ if ok {
133+ args = append([]any{"incarnation", record.IncarnationID.String()}, args...)
134+ }
135+ d.log.Info("durable_reconciliation_decision", args...)
129 }136 }
130 d.log.Info("durable_reconciliation_tick", "action", "scan", "cursor", next.DirectoryCookie, "done", next.DirectoryCookie == 0, "err", nil)137 d.log.Info("durable_reconciliation_tick", "action", "scan", "cursor", next.DirectoryCookie, "done", next.DirectoryCookie == 0, "err", nil)
131 if next.DirectoryCookie != 0 {138 if next.DirectoryCookie != 0 {
M internal/usecase/daemon/session_test.go +29 -0
@@ -851,6 +851,35 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
851851 require.Equal(t, uint64(9), d.mruSeq.Load())
852852 }
853853
854+// TestDaemonNewWithoutPersistenceLogsNoLoadWarning covers the default,
855+// persistence-free construction path (no WithStore/WithCatalogue option):
856+// d.persist is the persist.New(nil) sentinel, so LoadAll now returns
857+// errPersistenceUnavailable instead of an empty slice. That must not surface
858+// as a "loading persisted sessions failed" warning, since nothing failed —
859+// persistence simply was never configured.
860+func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
861+ var logBuf strings.Builder
862+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
863+ require.NotNil(t, d)
864+ require.False(t, d.persistEnabled)
865+ require.Empty(t, d.stopped)
866+ require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
867+}
868+
869+// TestDaemonNewWithConfiguredStoreStillLogsLoadFailure is the inverse of the
870+// above: when persistence IS configured (a real store via WithStore) and the
871+// load genuinely fails (an undecodable catalogue record here), the warning
872+// must still fire.
873+func TestDaemonNewWithConfiguredStoreStillLogsLoadFailure(t *testing.T) {
874+ store, state := newMockStore(t)
875+ state.data["bad"] = []byte{0, 1} // not a valid encoded catalogue record
876+ var logBuf strings.Builder
877+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)), WithStore(store))
878+ require.NotNil(t, d)
879+ require.True(t, d.persistEnabled)
880+ require.Contains(t, logBuf.String(), "loading persisted sessions failed")
881+}
882+
854883 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
855884 d := &Daemon{}
856885 sess := &session{}
@@ -851,6 +851,35 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
851 require.Equal(t, uint64(9), d.mruSeq.Load())851 require.Equal(t, uint64(9), d.mruSeq.Load())
852 }852 }
853 853
854+// TestDaemonNewWithoutPersistenceLogsNoLoadWarning covers the default,
855+// persistence-free construction path (no WithStore/WithCatalogue option):
856+// d.persist is the persist.New(nil) sentinel, so LoadAll now returns
857+// errPersistenceUnavailable instead of an empty slice. That must not surface
858+// as a "loading persisted sessions failed" warning, since nothing failed —
859+// persistence simply was never configured.
860+func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
861+ var logBuf strings.Builder
862+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
863+ require.NotNil(t, d)
864+ require.False(t, d.persistEnabled)
865+ require.Empty(t, d.stopped)
866+ require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
867+}
868+
869+// TestDaemonNewWithConfiguredStoreStillLogsLoadFailure is the inverse of the
870+// above: when persistence IS configured (a real store via WithStore) and the
871+// load genuinely fails (an undecodable catalogue record here), the warning
872+// must still fire.
873+func TestDaemonNewWithConfiguredStoreStillLogsLoadFailure(t *testing.T) {
874+ store, state := newMockStore(t)
875+ state.data["bad"] = []byte{0, 1} // not a valid encoded catalogue record
876+ var logBuf strings.Builder
877+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)), WithStore(store))
878+ require.NotNil(t, d)
879+ require.True(t, d.persistEnabled)
880+ require.Contains(t, logBuf.String(), "loading persisted sessions failed")
881+}
882+
854 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {883 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
855 d := &Daemon{}884 d := &Daemon{}
856 sess := &session{}885 sess := &session{}
M internal/usecase/daemon/snapshot_restore.go +24 -0
@@ -16,6 +16,30 @@ func (d *Daemon) closeRestoreDone() {
1616 d.restoreOnce.Do(func() { close(d.restoreDone) })
1717 }
1818
19+// startSnapshotRestoration launches catalogue-driven restoration as a durable
20+// writer. Restoration repairs HEADs, promotes fallbacks, and replaces catalogue
21+// records, so lifecycle ownership must outlive it exactly as it outlives the
22+// snapshot and maintenance workers. The completion channel is registered before
23+// the goroutine starts, so shutdown can never begin waiting while this writer
24+// is still unregistered.
25+func (d *Daemon) startSnapshotRestoration() {
26+ if d == nil {
27+ return
28+ }
29+ d.snapshotWorkerMu.Lock()
30+ if d.restoreWorkerDone != nil {
31+ d.snapshotWorkerMu.Unlock()
32+ return
33+ }
34+ done := make(chan struct{})
35+ d.restoreWorkerDone = done
36+ d.snapshotWorkerMu.Unlock()
37+ d.sessWg.Go(func() {
38+ defer close(done)
39+ d.restoreSnapshots(d.serveCtx)
40+ })
41+}
42+
1943 func (d *Daemon) restoreSnapshots(ctx context.Context) {
2044 if d == nil {
2145 return
@@ -16,6 +16,30 @@ func (d *Daemon) closeRestoreDone() {
16 d.restoreOnce.Do(func() { close(d.restoreDone) })16 d.restoreOnce.Do(func() { close(d.restoreDone) })
17 }17 }
18 18
19+// startSnapshotRestoration launches catalogue-driven restoration as a durable
20+// writer. Restoration repairs HEADs, promotes fallbacks, and replaces catalogue
21+// records, so lifecycle ownership must outlive it exactly as it outlives the
22+// snapshot and maintenance workers. The completion channel is registered before
23+// the goroutine starts, so shutdown can never begin waiting while this writer
24+// is still unregistered.
25+func (d *Daemon) startSnapshotRestoration() {
26+ if d == nil {
27+ return
28+ }
29+ d.snapshotWorkerMu.Lock()
30+ if d.restoreWorkerDone != nil {
31+ d.snapshotWorkerMu.Unlock()
32+ return
33+ }
34+ done := make(chan struct{})
35+ d.restoreWorkerDone = done
36+ d.snapshotWorkerMu.Unlock()
37+ d.sessWg.Go(func() {
38+ defer close(done)
39+ d.restoreSnapshots(d.serveCtx)
40+ })
41+}
42+
19 func (d *Daemon) restoreSnapshots(ctx context.Context) {43 func (d *Daemon) restoreSnapshots(ctx context.Context) {
20 if d == nil {44 if d == nil {
21 return45 return
M internal/usecase/daemon/snapshot_worker.go +9 -0
@@ -224,6 +224,9 @@ func (d *Daemon) StopDurableWriters(ctx context.Context) []string {
224224
225225 // WaitDurableWriters is the unconditional ownership barrier. It has no timeout
226226 // or status branch: every writer goroutine must exit before Serve may return.
227+// Restoration is joined with the others because it mutates durable state too;
228+// it observes serveCtx cancellation, so waiting for it cannot outlive an
229+// uncooperative repository call any longer than the snapshot worker does.
227230 func (d *Daemon) WaitDurableWriters() {
228231 if d == nil {
229232 return
@@ -231,6 +234,7 @@ func (d *Daemon) WaitDurableWriters() {
231234 d.snapshotWorkerMu.Lock()
232235 snapshotDone := d.snapshotWorkerDone
233236 maintenanceDone := d.maintenanceWorkerDone
237+ restoreDone := d.restoreWorkerDone
234238 d.snapshotWorkerMu.Unlock()
235239 if snapshotDone != nil {
236240 <-snapshotDone
@@ -239,6 +243,11 @@ func (d *Daemon) WaitDurableWriters() {
239243 if maintenanceDone != nil {
240244 <-maintenanceDone
241245 }
246+ // Restoration never blocks on the workers above (snapshot admission is
247+ // non-blocking), so joining it last cannot deadlock.
248+ if restoreDone != nil {
249+ <-restoreDone
250+ }
242251 }
243252
244253 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {
@@ -224,6 +224,9 @@ func (d *Daemon) StopDurableWriters(ctx context.Context) []string {
224 224
225 // WaitDurableWriters is the unconditional ownership barrier. It has no timeout225 // WaitDurableWriters is the unconditional ownership barrier. It has no timeout
226 // or status branch: every writer goroutine must exit before Serve may return.226 // or status branch: every writer goroutine must exit before Serve may return.
227+// Restoration is joined with the others because it mutates durable state too;
228+// it observes serveCtx cancellation, so waiting for it cannot outlive an
229+// uncooperative repository call any longer than the snapshot worker does.
227 func (d *Daemon) WaitDurableWriters() {230 func (d *Daemon) WaitDurableWriters() {
228 if d == nil {231 if d == nil {
229 return232 return
@@ -231,6 +234,7 @@ func (d *Daemon) WaitDurableWriters() {
231 d.snapshotWorkerMu.Lock()234 d.snapshotWorkerMu.Lock()
232 snapshotDone := d.snapshotWorkerDone235 snapshotDone := d.snapshotWorkerDone
233 maintenanceDone := d.maintenanceWorkerDone236 maintenanceDone := d.maintenanceWorkerDone
237+ restoreDone := d.restoreWorkerDone
234 d.snapshotWorkerMu.Unlock()238 d.snapshotWorkerMu.Unlock()
235 if snapshotDone != nil {239 if snapshotDone != nil {
236 <-snapshotDone240 <-snapshotDone
@@ -239,6 +243,11 @@ func (d *Daemon) WaitDurableWriters() {
239 if maintenanceDone != nil {243 if maintenanceDone != nil {
240 <-maintenanceDone244 <-maintenanceDone
241 }245 }
246+ // Restoration never blocks on the workers above (snapshot admission is
247+ // non-blocking), so joining it last cannot deadlock.
248+ if restoreDone != nil {
249+ <-restoreDone
250+ }
242 }251 }
243 252
244 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {253 func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {

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