refactor(daemon): extract snapshot responsibilities
- commit
- e8e2f009d2010674b6ae82f6f084c84ed5391e09
- author
- brice · · 2026-07-22 01:31 UTC
- parent
- 476768f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +430 -355
A internal/usecase/daemon/snapshot_capture.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 4 | + | ||
| 5 | + | // captureSession rotates history tails and clones visible frames while holding | |
| 6 | + | // each pane lock. The returned capture contains only immutable state; encoding | |
| 7 | + | // and persistence are deliberately deferred to snapshotEncodeWorker. | |
| 8 | + | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| 9 | + | sess.mu.Lock() | |
| 10 | + | capture := &snapshotCapture{ | |
| 11 | + | session: sess, | |
| 12 | + | generation: generation, | |
| 13 | + | name: sess.name, | |
| 14 | + | createdAt: uint64(sess.createdAt), | |
| 15 | + | active: uint16(max(sess.active, 0)), | |
| 16 | + | } | |
| 17 | + | ephemeral := sess.ephemeral | |
| 18 | + | fallbackCwd := sess.cwd | |
| 19 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 20 | + | sess.mu.Unlock() | |
| 21 | + | if ephemeral || capture.name == "" { | |
| 22 | + | return capture, false | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |
| 26 | + | for _, tb := range tabs { | |
| 27 | + | tb.mu.Lock() | |
| 28 | + | tabCapture := snapshotCaptureTab{ | |
| 29 | + | stableID: tb.stableID, | |
| 30 | + | cols: uint16(max(tb.size.Cols, 0)), | |
| 31 | + | rows: uint16(max(tb.size.Rows, 0)), | |
| 32 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 33 | + | tree: tb.tree.Clone(), | |
| 34 | + | } | |
| 35 | + | if tb.tree != nil { | |
| 36 | + | tabCapture.focus = tb.tree.Focus | |
| 37 | + | } | |
| 38 | + | panes := make([]*pane, 0, len(tb.panes)) | |
| 39 | + | for _, p := range tb.panes { | |
| 40 | + | panes = append(panes, p) | |
| 41 | + | } | |
| 42 | + | tb.mu.Unlock() | |
| 43 | + | ||
| 44 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |
| 45 | + | for _, p := range panes { | |
| 46 | + | p.mu.Lock() | |
| 47 | + | pty := p.pty | |
| 48 | + | pid := 0 | |
| 49 | + | if pty != nil { | |
| 50 | + | pid = pty.Pid() | |
| 51 | + | } | |
| 52 | + | paneCapture := snapshotCapturePane{ | |
| 53 | + | id: p.id, | |
| 54 | + | stableID: p.stableID, | |
| 55 | + | visible: p.screen.PrimaryVisibleSnapshot(), | |
| 56 | + | } | |
| 57 | + | paneCapture.sealed = p.history.SnapshotView() | |
| 58 | + | paneCapture.tail = paneCapture.sealed.Tail() | |
| 59 | + | p.mu.Unlock() | |
| 60 | + | paneCapture.cwd = fallbackCwd | |
| 61 | + | if d.procCwd != nil && pid > 0 { | |
| 62 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 63 | + | paneCapture.cwd = cwd | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | paneCapture.process = d.capturePaneProcess(pty, pid) | |
| 67 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) | |
| 68 | + | } | |
| 69 | + | capture.tabs = append(capture.tabs, tabCapture) | |
| 70 | + | } | |
| 71 | + | return capture, true | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // captureSession remains the synchronous producer-facing trigger for callers | |
| 75 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |
| 76 | + | func (d *Daemon) captureSession(sess *session) bool { | |
| 77 | + | markSnapshotDirty(sess) | |
| 78 | + | return d.scheduleSnapshot(sess) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 82 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | pgid, err := pty.ForegroundPgid() | |
| 86 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 90 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | strategy := detectProcessStrategy(argv) | |
| 94 | + | return &snapcodec.Process{ | |
| 95 | + | Argv: append([]string(nil), argv...), | |
| 96 | + | Strategy: strategy, | |
| 97 | + | Opts: snapcodec.ProcessOpts{ | |
| 98 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 4 | + | ||||
| 5 | + | // captureSession rotates history tails and clones visible frames while holding | |||
| 6 | + | // each pane lock. The returned capture contains only immutable state; encoding | |||
| 7 | + | // and persistence are deliberately deferred to snapshotEncodeWorker. | |||
| 8 | + | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |||
| 9 | + | sess.mu.Lock() | |||
| 10 | + | capture := &snapshotCapture{ | |||
| 11 | + | session: sess, | |||
| 12 | + | generation: generation, | |||
| 13 | + | name: sess.name, | |||
| 14 | + | createdAt: uint64(sess.createdAt), | |||
| 15 | + | active: uint16(max(sess.active, 0)), | |||
| 16 | + | } | |||
| 17 | + | ephemeral := sess.ephemeral | |||
| 18 | + | fallbackCwd := sess.cwd | |||
| 19 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 20 | + | sess.mu.Unlock() | |||
| 21 | + | if ephemeral || capture.name == "" { | |||
| 22 | + | return capture, false | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |||
| 26 | + | for _, tb := range tabs { | |||
| 27 | + | tb.mu.Lock() | |||
| 28 | + | tabCapture := snapshotCaptureTab{ | |||
| 29 | + | stableID: tb.stableID, | |||
| 30 | + | cols: uint16(max(tb.size.Cols, 0)), | |||
| 31 | + | rows: uint16(max(tb.size.Rows, 0)), | |||
| 32 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |||
| 33 | + | tree: tb.tree.Clone(), | |||
| 34 | + | } | |||
| 35 | + | if tb.tree != nil { | |||
| 36 | + | tabCapture.focus = tb.tree.Focus | |||
| 37 | + | } | |||
| 38 | + | panes := make([]*pane, 0, len(tb.panes)) | |||
| 39 | + | for _, p := range tb.panes { | |||
| 40 | + | panes = append(panes, p) | |||
| 41 | + | } | |||
| 42 | + | tb.mu.Unlock() | |||
| 43 | + | ||||
| 44 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |||
| 45 | + | for _, p := range panes { | |||
| 46 | + | p.mu.Lock() | |||
| 47 | + | pty := p.pty | |||
| 48 | + | pid := 0 | |||
| 49 | + | if pty != nil { | |||
| 50 | + | pid = pty.Pid() | |||
| 51 | + | } | |||
| 52 | + | paneCapture := snapshotCapturePane{ | |||
| 53 | + | id: p.id, | |||
| 54 | + | stableID: p.stableID, | |||
| 55 | + | visible: p.screen.PrimaryVisibleSnapshot(), | |||
| 56 | + | } | |||
| 57 | + | paneCapture.sealed = p.history.SnapshotView() | |||
| 58 | + | paneCapture.tail = paneCapture.sealed.Tail() | |||
| 59 | + | p.mu.Unlock() | |||
| 60 | + | paneCapture.cwd = fallbackCwd | |||
| 61 | + | if d.procCwd != nil && pid > 0 { | |||
| 62 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |||
| 63 | + | paneCapture.cwd = cwd | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | paneCapture.process = d.capturePaneProcess(pty, pid) | |||
| 67 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) | |||
| 68 | + | } | |||
| 69 | + | capture.tabs = append(capture.tabs, tabCapture) | |||
| 70 | + | } | |||
| 71 | + | return capture, true | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // captureSession remains the synchronous producer-facing trigger for callers | |||
| 75 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |||
| 76 | + | func (d *Daemon) captureSession(sess *session) bool { | |||
| 77 | + | markSnapshotDirty(sess) | |||
| 78 | + | return d.scheduleSnapshot(sess) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |||
| 82 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | pgid, err := pty.ForegroundPgid() | |||
| 86 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |||
| 90 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | strategy := detectProcessStrategy(argv) | |||
| 94 | + | return &snapcodec.Process{ | |||
| 95 | + | Argv: append([]string(nil), argv...), | |||
| 96 | + | Strategy: strategy, | |||
| 97 | + | Opts: snapcodec.ProcessOpts{ | |||
| 98 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |||
| 99 | + | }, | |||
| 100 | + | } | |||
| 101 | + | } | |||
M internal/usecase/daemon/snapshot_coordinator.go +0 -232
| @@ -3,8 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | - | ||
| 7 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 8 | 6 | ) | |
| 9 | 7 | ||
| 10 | 8 | func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) { | |
| @@ -156,83 +154,6 @@ func (sess *session) signalSnapshotChangedLocked() { | |||
| 156 | 154 | sess.snapshotChanged = make(chan struct{}) | |
| 157 | 155 | } | |
| 158 | 156 | ||
| 159 | - | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |
| 160 | - | return d.scheduleSnapshotWithFinalFallback(sess, false) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 164 | - | // is pending. A removed session cannot be retried by the repository scheduler, so the | |
| 165 | - | // worker drains its retained terminal state before it stops. | |
| 166 | - | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 167 | - | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 171 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |
| 172 | - | return true | |
| 173 | - | } | |
| 174 | - | kind := snapshotAttemptRoutine | |
| 175 | - | if final { | |
| 176 | - | kind = snapshotAttemptForced | |
| 177 | - | } | |
| 178 | - | sess.snapshotMu.Lock() | |
| 179 | - | if sess.snapshotQuarantined || !sess.snapDirty.Load() { | |
| 180 | - | available := !sess.snapshotQuarantined | |
| 181 | - | sess.snapshotMu.Unlock() | |
| 182 | - | return available | |
| 183 | - | } | |
| 184 | - | if final && sess.snapshotGeneration > sess.snapshotForcedGeneration { | |
| 185 | - | // Do not let an older queued or in-flight routine publication satisfy a | |
| 186 | - | // terminal checkpoint. The intent is retained until a publication at | |
| 187 | - | // this generation (or newer) succeeds. | |
| 188 | - | sess.snapshotForcedGeneration = sess.snapshotGeneration | |
| 189 | - | } | |
| 190 | - | if sess.snapshotPending || | |
| 191 | - | (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) { | |
| 192 | - | sess.snapshotMu.Unlock() | |
| 193 | - | return true | |
| 194 | - | } | |
| 195 | - | if sess.snapshotPublicationContext == nil { | |
| 196 | - | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |
| 197 | - | } | |
| 198 | - | generation := sess.snapshotGeneration | |
| 199 | - | publicationContext := sess.snapshotPublicationContext | |
| 200 | - | sess.snapshotCapturedGeneration = generation | |
| 201 | - | sess.snapshotAttempted = true | |
| 202 | - | sess.snapshotAttemptKind = kind | |
| 203 | - | sess.snapshotPendingCaptures++ | |
| 204 | - | sess.snapshotPending = true | |
| 205 | - | sess.signalSnapshotChangedLocked() | |
| 206 | - | sess.snapshotMu.Unlock() | |
| 207 | - | ||
| 208 | - | capture, ok := d.captureSnapshotState(sess, generation) | |
| 209 | - | if capture != nil { | |
| 210 | - | capture.attemptKind = kind | |
| 211 | - | capture.publicationContext = publicationContext | |
| 212 | - | } | |
| 213 | - | if !ok { | |
| 214 | - | d.finishSnapshotCapture(capture, false) | |
| 215 | - | return false | |
| 216 | - | } | |
| 217 | - | sess.snapshotMu.Lock() | |
| 218 | - | quarantined := sess.snapshotQuarantined | |
| 219 | - | if !quarantined { | |
| 220 | - | sess.snapshotQueuedCapture = capture | |
| 221 | - | } | |
| 222 | - | sess.snapshotMu.Unlock() | |
| 223 | - | if quarantined { | |
| 224 | - | d.finishSnapshotCapture(capture, false) | |
| 225 | - | return false | |
| 226 | - | } | |
| 227 | - | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |
| 228 | - | return true | |
| 229 | - | } | |
| 230 | - | // Coalesce under saturation or shutdown: the latest state stays dirty and | |
| 231 | - | // will be captured on a later tick once an active worker has room. | |
| 232 | - | d.finishSnapshotCapture(capture, false) | |
| 233 | - | return false | |
| 234 | - | } | |
| 235 | - | ||
| 236 | 157 | // waitForSnapshotGeneration waits only for the bounded terminal checkpoint | |
| 237 | 158 | // deadline. It observes successful publication, rather than dirty state, so a | |
| 238 | 159 | // concurrent later mutation cannot turn a completed forced checkpoint into a | |
| @@ -272,137 +193,6 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation | |||
| 272 | 193 | } | |
| 273 | 194 | } | |
| 274 | 195 | ||
| 275 | - | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |
| 276 | - | // queue send. The queue is deliberately never closed: producers can race | |
| 277 | - | // shutdown without risking a send-on-closed panic. | |
| 278 | - | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |
| 279 | - | d.snapshotWorkerMu.Lock() | |
| 280 | - | defer d.snapshotWorkerMu.Unlock() | |
| 281 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 282 | - | return false | |
| 283 | - | } | |
| 284 | - | select { | |
| 285 | - | case d.snapshotJobs <- capture: | |
| 286 | - | return true | |
| 287 | - | default: | |
| 288 | - | return false | |
| 289 | - | } | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |
| 293 | - | // without making session teardown wait behind its normal bounded queue. A | |
| 294 | - | // blocked worker retains at most one terminal capture per session, replacing | |
| 295 | - | // stale state with the newest immutable capture. | |
| 296 | - | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |
| 297 | - | if capture == nil || capture.session == nil { | |
| 298 | - | return false | |
| 299 | - | } | |
| 300 | - | d.snapshotWorkerMu.Lock() | |
| 301 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 302 | - | d.snapshotWorkerMu.Unlock() | |
| 303 | - | return false | |
| 304 | - | } | |
| 305 | - | if d.snapshotFinalJobs == nil { | |
| 306 | - | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 307 | - | } | |
| 308 | - | replaced, exists := d.snapshotFinalJobs[capture.session] | |
| 309 | - | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |
| 310 | - | d.snapshotWorkerMu.Unlock() | |
| 311 | - | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |
| 312 | - | return false | |
| 313 | - | } | |
| 314 | - | // Finish the stale capture before publishing its replacement. Otherwise a | |
| 315 | - | // fast worker could persist the replacement and then have this stale failure | |
| 316 | - | // mark the session dirty again. | |
| 317 | - | d.finishSnapshotCapture(replaced, false) | |
| 318 | - | if !exists { | |
| 319 | - | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |
| 320 | - | } | |
| 321 | - | d.snapshotFinalJobs[capture.session] = capture | |
| 322 | - | select { | |
| 323 | - | case d.snapshotWorkerFinalWake <- struct{}{}: | |
| 324 | - | default: | |
| 325 | - | } | |
| 326 | - | d.snapshotWorkerMu.Unlock() | |
| 327 | - | return true | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | // captureSession rotates history tails and clones visible frames while holding | |
| 331 | - | // each pane lock. The returned capture contains only immutable state; encoding | |
| 332 | - | // and persistence are deliberately deferred to snapshotEncodeWorker. | |
| 333 | - | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| 334 | - | sess.mu.Lock() | |
| 335 | - | capture := &snapshotCapture{ | |
| 336 | - | session: sess, | |
| 337 | - | generation: generation, | |
| 338 | - | name: sess.name, | |
| 339 | - | createdAt: uint64(sess.createdAt), | |
| 340 | - | active: uint16(max(sess.active, 0)), | |
| 341 | - | } | |
| 342 | - | ephemeral := sess.ephemeral | |
| 343 | - | fallbackCwd := sess.cwd | |
| 344 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 345 | - | sess.mu.Unlock() | |
| 346 | - | if ephemeral || capture.name == "" { | |
| 347 | - | return capture, false | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |
| 351 | - | for _, tb := range tabs { | |
| 352 | - | tb.mu.Lock() | |
| 353 | - | tabCapture := snapshotCaptureTab{ | |
| 354 | - | stableID: tb.stableID, | |
| 355 | - | cols: uint16(max(tb.size.Cols, 0)), | |
| 356 | - | rows: uint16(max(tb.size.Rows, 0)), | |
| 357 | - | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 358 | - | tree: tb.tree.Clone(), | |
| 359 | - | } | |
| 360 | - | if tb.tree != nil { | |
| 361 | - | tabCapture.focus = tb.tree.Focus | |
| 362 | - | } | |
| 363 | - | panes := make([]*pane, 0, len(tb.panes)) | |
| 364 | - | for _, p := range tb.panes { | |
| 365 | - | panes = append(panes, p) | |
| 366 | - | } | |
| 367 | - | tb.mu.Unlock() | |
| 368 | - | ||
| 369 | - | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |
| 370 | - | for _, p := range panes { | |
| 371 | - | p.mu.Lock() | |
| 372 | - | pty := p.pty | |
| 373 | - | pid := 0 | |
| 374 | - | if pty != nil { | |
| 375 | - | pid = pty.Pid() | |
| 376 | - | } | |
| 377 | - | paneCapture := snapshotCapturePane{ | |
| 378 | - | id: p.id, | |
| 379 | - | stableID: p.stableID, | |
| 380 | - | visible: p.screen.PrimaryVisibleSnapshot(), | |
| 381 | - | } | |
| 382 | - | paneCapture.sealed = p.history.SnapshotView() | |
| 383 | - | paneCapture.tail = paneCapture.sealed.Tail() | |
| 384 | - | p.mu.Unlock() | |
| 385 | - | paneCapture.cwd = fallbackCwd | |
| 386 | - | if d.procCwd != nil && pid > 0 { | |
| 387 | - | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 388 | - | paneCapture.cwd = cwd | |
| 389 | - | } | |
| 390 | - | } | |
| 391 | - | paneCapture.process = d.capturePaneProcess(pty, pid) | |
| 392 | - | tabCapture.panes = append(tabCapture.panes, paneCapture) | |
| 393 | - | } | |
| 394 | - | capture.tabs = append(capture.tabs, tabCapture) | |
| 395 | - | } | |
| 396 | - | return capture, true | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | // captureSession remains the synchronous producer-facing trigger for callers | |
| 400 | - | // such as teardown and benchmarks; the actual encoding and Write stay async. | |
| 401 | - | func (d *Daemon) captureSession(sess *session) bool { | |
| 402 | - | markSnapshotDirty(sess) | |
| 403 | - | return d.scheduleSnapshot(sess) | |
| 404 | - | } | |
| 405 | - | ||
| 406 | 196 | // startSnapshotPublication moves a retained capture from the producer queue to | |
| 407 | 197 | // the repository worker. Quarantine and this transition share snapshotMu, so a | |
| 408 | 198 | // repository call cannot begin after destructive teardown has quarantined the | |
| @@ -498,28 +288,6 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) | |||
| 498 | 288 | // blocked disk from repeatedly repainting every attached client while retaining | |
| 499 | 289 | // the count in notification history. | |
| 500 | 290 | ||
| 501 | - | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 502 | - | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 503 | - | return nil | |
| 504 | - | } | |
| 505 | - | pgid, err := pty.ForegroundPgid() | |
| 506 | - | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 507 | - | return nil | |
| 508 | - | } | |
| 509 | - | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 510 | - | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 511 | - | return nil | |
| 512 | - | } | |
| 513 | - | strategy := detectProcessStrategy(argv) | |
| 514 | - | return &snapcodec.Process{ | |
| 515 | - | Argv: append([]string(nil), argv...), | |
| 516 | - | Strategy: strategy, | |
| 517 | - | Opts: snapcodec.ProcessOpts{ | |
| 518 | - | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 519 | - | }, | |
| 520 | - | } | |
| 521 | - | } | |
| 522 | - | ||
| 523 | 291 | // snapshotRepositorySaver waits until the earliest completion-derived routine | |
| 524 | 292 | // eligibility. State changes wake it immediately, so a newly dirty session is | |
| 525 | 293 | // captured without waiting for an unrelated session's interval. | |
| @@ -3,8 +3,6 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | - | ||||
| 7 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 8 | ) | 6 | ) | ||
| 9 | 7 | ||||
| 10 | func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) { | 8 | func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) { | ||
| @@ -156,83 +154,6 @@ func (sess *session) signalSnapshotChangedLocked() { | |||||
| 156 | sess.snapshotChanged = make(chan struct{}) | 154 | sess.snapshotChanged = make(chan struct{}) | ||
| 157 | } | 155 | } | ||
| 158 | 156 | ||||
| 159 | - | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||
| 160 | - | return d.scheduleSnapshotWithFinalFallback(sess, false) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // scheduleFinalSnapshot captures the terminal state even when an older capture | |||
| 164 | - | // is pending. A removed session cannot be retried by the repository scheduler, so the | |||
| 165 | - | // worker drains its retained terminal state before it stops. | |||
| 166 | - | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |||
| 167 | - | return d.scheduleSnapshotWithFinalFallback(sess, true) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |||
| 171 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |||
| 172 | - | return true | |||
| 173 | - | } | |||
| 174 | - | kind := snapshotAttemptRoutine | |||
| 175 | - | if final { | |||
| 176 | - | kind = snapshotAttemptForced | |||
| 177 | - | } | |||
| 178 | - | sess.snapshotMu.Lock() | |||
| 179 | - | if sess.snapshotQuarantined || !sess.snapDirty.Load() { | |||
| 180 | - | available := !sess.snapshotQuarantined | |||
| 181 | - | sess.snapshotMu.Unlock() | |||
| 182 | - | return available | |||
| 183 | - | } | |||
| 184 | - | if final && sess.snapshotGeneration > sess.snapshotForcedGeneration { | |||
| 185 | - | // Do not let an older queued or in-flight routine publication satisfy a | |||
| 186 | - | // terminal checkpoint. The intent is retained until a publication at | |||
| 187 | - | // this generation (or newer) succeeds. | |||
| 188 | - | sess.snapshotForcedGeneration = sess.snapshotGeneration | |||
| 189 | - | } | |||
| 190 | - | if sess.snapshotPending || | |||
| 191 | - | (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) { | |||
| 192 | - | sess.snapshotMu.Unlock() | |||
| 193 | - | return true | |||
| 194 | - | } | |||
| 195 | - | if sess.snapshotPublicationContext == nil { | |||
| 196 | - | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |||
| 197 | - | } | |||
| 198 | - | generation := sess.snapshotGeneration | |||
| 199 | - | publicationContext := sess.snapshotPublicationContext | |||
| 200 | - | sess.snapshotCapturedGeneration = generation | |||
| 201 | - | sess.snapshotAttempted = true | |||
| 202 | - | sess.snapshotAttemptKind = kind | |||
| 203 | - | sess.snapshotPendingCaptures++ | |||
| 204 | - | sess.snapshotPending = true | |||
| 205 | - | sess.signalSnapshotChangedLocked() | |||
| 206 | - | sess.snapshotMu.Unlock() | |||
| 207 | - | ||||
| 208 | - | capture, ok := d.captureSnapshotState(sess, generation) | |||
| 209 | - | if capture != nil { | |||
| 210 | - | capture.attemptKind = kind | |||
| 211 | - | capture.publicationContext = publicationContext | |||
| 212 | - | } | |||
| 213 | - | if !ok { | |||
| 214 | - | d.finishSnapshotCapture(capture, false) | |||
| 215 | - | return false | |||
| 216 | - | } | |||
| 217 | - | sess.snapshotMu.Lock() | |||
| 218 | - | quarantined := sess.snapshotQuarantined | |||
| 219 | - | if !quarantined { | |||
| 220 | - | sess.snapshotQueuedCapture = capture | |||
| 221 | - | } | |||
| 222 | - | sess.snapshotMu.Unlock() | |||
| 223 | - | if quarantined { | |||
| 224 | - | d.finishSnapshotCapture(capture, false) | |||
| 225 | - | return false | |||
| 226 | - | } | |||
| 227 | - | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |||
| 228 | - | return true | |||
| 229 | - | } | |||
| 230 | - | // Coalesce under saturation or shutdown: the latest state stays dirty and | |||
| 231 | - | // will be captured on a later tick once an active worker has room. | |||
| 232 | - | d.finishSnapshotCapture(capture, false) | |||
| 233 | - | return false | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | // waitForSnapshotGeneration waits only for the bounded terminal checkpoint | 157 | // waitForSnapshotGeneration waits only for the bounded terminal checkpoint | ||
| 237 | // deadline. It observes successful publication, rather than dirty state, so a | 158 | // deadline. It observes successful publication, rather than dirty state, so a | ||
| 238 | // concurrent later mutation cannot turn a completed forced checkpoint into a | 159 | // concurrent later mutation cannot turn a completed forced checkpoint into a | ||
| @@ -272,137 +193,6 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation | |||||
| 272 | } | 193 | } | ||
| 273 | } | 194 | } | ||
| 274 | 195 | ||||
| 275 | - | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |||
| 276 | - | // queue send. The queue is deliberately never closed: producers can race | |||
| 277 | - | // shutdown without risking a send-on-closed panic. | |||
| 278 | - | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |||
| 279 | - | d.snapshotWorkerMu.Lock() | |||
| 280 | - | defer d.snapshotWorkerMu.Unlock() | |||
| 281 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 282 | - | return false | |||
| 283 | - | } | |||
| 284 | - | select { | |||
| 285 | - | case d.snapshotJobs <- capture: | |||
| 286 | - | return true | |||
| 287 | - | default: | |||
| 288 | - | return false | |||
| 289 | - | } | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |||
| 293 | - | // without making session teardown wait behind its normal bounded queue. A | |||
| 294 | - | // blocked worker retains at most one terminal capture per session, replacing | |||
| 295 | - | // stale state with the newest immutable capture. | |||
| 296 | - | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |||
| 297 | - | if capture == nil || capture.session == nil { | |||
| 298 | - | return false | |||
| 299 | - | } | |||
| 300 | - | d.snapshotWorkerMu.Lock() | |||
| 301 | - | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 302 | - | d.snapshotWorkerMu.Unlock() | |||
| 303 | - | return false | |||
| 304 | - | } | |||
| 305 | - | if d.snapshotFinalJobs == nil { | |||
| 306 | - | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |||
| 307 | - | } | |||
| 308 | - | replaced, exists := d.snapshotFinalJobs[capture.session] | |||
| 309 | - | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |||
| 310 | - | d.snapshotWorkerMu.Unlock() | |||
| 311 | - | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |||
| 312 | - | return false | |||
| 313 | - | } | |||
| 314 | - | // Finish the stale capture before publishing its replacement. Otherwise a | |||
| 315 | - | // fast worker could persist the replacement and then have this stale failure | |||
| 316 | - | // mark the session dirty again. | |||
| 317 | - | d.finishSnapshotCapture(replaced, false) | |||
| 318 | - | if !exists { | |||
| 319 | - | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |||
| 320 | - | } | |||
| 321 | - | d.snapshotFinalJobs[capture.session] = capture | |||
| 322 | - | select { | |||
| 323 | - | case d.snapshotWorkerFinalWake <- struct{}{}: | |||
| 324 | - | default: | |||
| 325 | - | } | |||
| 326 | - | d.snapshotWorkerMu.Unlock() | |||
| 327 | - | return true | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | // captureSession rotates history tails and clones visible frames while holding | |||
| 331 | - | // each pane lock. The returned capture contains only immutable state; encoding | |||
| 332 | - | // and persistence are deliberately deferred to snapshotEncodeWorker. | |||
| 333 | - | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |||
| 334 | - | sess.mu.Lock() | |||
| 335 | - | capture := &snapshotCapture{ | |||
| 336 | - | session: sess, | |||
| 337 | - | generation: generation, | |||
| 338 | - | name: sess.name, | |||
| 339 | - | createdAt: uint64(sess.createdAt), | |||
| 340 | - | active: uint16(max(sess.active, 0)), | |||
| 341 | - | } | |||
| 342 | - | ephemeral := sess.ephemeral | |||
| 343 | - | fallbackCwd := sess.cwd | |||
| 344 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 345 | - | sess.mu.Unlock() | |||
| 346 | - | if ephemeral || capture.name == "" { | |||
| 347 | - | return capture, false | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | capture.tabs = make([]snapshotCaptureTab, 0, len(tabs)) | |||
| 351 | - | for _, tb := range tabs { | |||
| 352 | - | tb.mu.Lock() | |||
| 353 | - | tabCapture := snapshotCaptureTab{ | |||
| 354 | - | stableID: tb.stableID, | |||
| 355 | - | cols: uint16(max(tb.size.Cols, 0)), | |||
| 356 | - | rows: uint16(max(tb.size.Rows, 0)), | |||
| 357 | - | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |||
| 358 | - | tree: tb.tree.Clone(), | |||
| 359 | - | } | |||
| 360 | - | if tb.tree != nil { | |||
| 361 | - | tabCapture.focus = tb.tree.Focus | |||
| 362 | - | } | |||
| 363 | - | panes := make([]*pane, 0, len(tb.panes)) | |||
| 364 | - | for _, p := range tb.panes { | |||
| 365 | - | panes = append(panes, p) | |||
| 366 | - | } | |||
| 367 | - | tb.mu.Unlock() | |||
| 368 | - | ||||
| 369 | - | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |||
| 370 | - | for _, p := range panes { | |||
| 371 | - | p.mu.Lock() | |||
| 372 | - | pty := p.pty | |||
| 373 | - | pid := 0 | |||
| 374 | - | if pty != nil { | |||
| 375 | - | pid = pty.Pid() | |||
| 376 | - | } | |||
| 377 | - | paneCapture := snapshotCapturePane{ | |||
| 378 | - | id: p.id, | |||
| 379 | - | stableID: p.stableID, | |||
| 380 | - | visible: p.screen.PrimaryVisibleSnapshot(), | |||
| 381 | - | } | |||
| 382 | - | paneCapture.sealed = p.history.SnapshotView() | |||
| 383 | - | paneCapture.tail = paneCapture.sealed.Tail() | |||
| 384 | - | p.mu.Unlock() | |||
| 385 | - | paneCapture.cwd = fallbackCwd | |||
| 386 | - | if d.procCwd != nil && pid > 0 { | |||
| 387 | - | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |||
| 388 | - | paneCapture.cwd = cwd | |||
| 389 | - | } | |||
| 390 | - | } | |||
| 391 | - | paneCapture.process = d.capturePaneProcess(pty, pid) | |||
| 392 | - | tabCapture.panes = append(tabCapture.panes, paneCapture) | |||
| 393 | - | } | |||
| 394 | - | capture.tabs = append(capture.tabs, tabCapture) | |||
| 395 | - | } | |||
| 396 | - | return capture, true | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | // captureSession remains the synchronous producer-facing trigger for callers | |||
| 400 | - | // such as teardown and benchmarks; the actual encoding and Write stay async. | |||
| 401 | - | func (d *Daemon) captureSession(sess *session) bool { | |||
| 402 | - | markSnapshotDirty(sess) | |||
| 403 | - | return d.scheduleSnapshot(sess) | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | // startSnapshotPublication moves a retained capture from the producer queue to | 196 | // startSnapshotPublication moves a retained capture from the producer queue to | ||
| 407 | // the repository worker. Quarantine and this transition share snapshotMu, so a | 197 | // the repository worker. Quarantine and this transition share snapshotMu, so a | ||
| 408 | // repository call cannot begin after destructive teardown has quarantined the | 198 | // repository call cannot begin after destructive teardown has quarantined the | ||
| @@ -498,28 +288,6 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) | |||||
| 498 | // blocked disk from repeatedly repainting every attached client while retaining | 288 | // blocked disk from repeatedly repainting every attached client while retaining | ||
| 499 | // the count in notification history. | 289 | // the count in notification history. | ||
| 500 | 290 | ||||
| 501 | - | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |||
| 502 | - | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |||
| 503 | - | return nil | |||
| 504 | - | } | |||
| 505 | - | pgid, err := pty.ForegroundPgid() | |||
| 506 | - | if err != nil || pgid <= 0 || pgid == shellPid { | |||
| 507 | - | return nil | |||
| 508 | - | } | |||
| 509 | - | argv, err := d.procGroupArgv(pgid, shellPid) | |||
| 510 | - | if err != nil || len(argv) == 0 || argv[0] == "" { | |||
| 511 | - | return nil | |||
| 512 | - | } | |||
| 513 | - | strategy := detectProcessStrategy(argv) | |||
| 514 | - | return &snapcodec.Process{ | |||
| 515 | - | Argv: append([]string(nil), argv...), | |||
| 516 | - | Strategy: strategy, | |||
| 517 | - | Opts: snapcodec.ProcessOpts{ | |||
| 518 | - | AgentSessionID: extractAgentSessionID(strategy, argv), | |||
| 519 | - | }, | |||
| 520 | - | } | |||
| 521 | - | } | |||
| 522 | - | ||||
| 523 | // snapshotRepositorySaver waits until the earliest completion-derived routine | 291 | // snapshotRepositorySaver waits until the earliest completion-derived routine | ||
| 524 | // eligibility. State changes wake it immediately, so a newly dirty session is | 292 | // eligibility. State changes wake it immediately, so a newly dirty session is | ||
| 525 | // captured without waiting for an unrelated session's interval. | 293 | // captured without waiting for an unrelated session's interval. | ||
A internal/usecase/daemon/snapshot_queue.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |
| 6 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 10 | + | // is pending. A removed session cannot be retried by the repository scheduler, so the | |
| 11 | + | // worker drains its retained terminal state before it stops. | |
| 12 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 13 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 17 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |
| 18 | + | return true | |
| 19 | + | } | |
| 20 | + | kind := snapshotAttemptRoutine | |
| 21 | + | if final { | |
| 22 | + | kind = snapshotAttemptForced | |
| 23 | + | } | |
| 24 | + | sess.snapshotMu.Lock() | |
| 25 | + | if sess.snapshotQuarantined || !sess.snapDirty.Load() { | |
| 26 | + | available := !sess.snapshotQuarantined | |
| 27 | + | sess.snapshotMu.Unlock() | |
| 28 | + | return available | |
| 29 | + | } | |
| 30 | + | if final && sess.snapshotGeneration > sess.snapshotForcedGeneration { | |
| 31 | + | // Do not let an older queued or in-flight routine publication satisfy a | |
| 32 | + | // terminal checkpoint. The intent is retained until a publication at | |
| 33 | + | // this generation (or newer) succeeds. | |
| 34 | + | sess.snapshotForcedGeneration = sess.snapshotGeneration | |
| 35 | + | } | |
| 36 | + | if sess.snapshotPending || | |
| 37 | + | (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) { | |
| 38 | + | sess.snapshotMu.Unlock() | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | if sess.snapshotPublicationContext == nil { | |
| 42 | + | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |
| 43 | + | } | |
| 44 | + | generation := sess.snapshotGeneration | |
| 45 | + | publicationContext := sess.snapshotPublicationContext | |
| 46 | + | sess.snapshotCapturedGeneration = generation | |
| 47 | + | sess.snapshotAttempted = true | |
| 48 | + | sess.snapshotAttemptKind = kind | |
| 49 | + | sess.snapshotPendingCaptures++ | |
| 50 | + | sess.snapshotPending = true | |
| 51 | + | sess.signalSnapshotChangedLocked() | |
| 52 | + | sess.snapshotMu.Unlock() | |
| 53 | + | ||
| 54 | + | capture, ok := d.captureSnapshotState(sess, generation) | |
| 55 | + | if capture != nil { | |
| 56 | + | capture.attemptKind = kind | |
| 57 | + | capture.publicationContext = publicationContext | |
| 58 | + | } | |
| 59 | + | if !ok { | |
| 60 | + | d.finishSnapshotCapture(capture, false) | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | sess.snapshotMu.Lock() | |
| 64 | + | quarantined := sess.snapshotQuarantined | |
| 65 | + | if !quarantined { | |
| 66 | + | sess.snapshotQueuedCapture = capture | |
| 67 | + | } | |
| 68 | + | sess.snapshotMu.Unlock() | |
| 69 | + | if quarantined { | |
| 70 | + | d.finishSnapshotCapture(capture, false) | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |
| 74 | + | return true | |
| 75 | + | } | |
| 76 | + | // Coalesce under saturation or shutdown: the latest state stays dirty and | |
| 77 | + | // will be captured on a later tick once an active worker has room. | |
| 78 | + | d.finishSnapshotCapture(capture, false) | |
| 79 | + | return false | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |
| 83 | + | // queue send. The queue is deliberately never closed: producers can race | |
| 84 | + | // shutdown without risking a send-on-closed panic. | |
| 85 | + | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |
| 86 | + | d.snapshotWorkerMu.Lock() | |
| 87 | + | defer d.snapshotWorkerMu.Unlock() | |
| 88 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | select { | |
| 92 | + | case d.snapshotJobs <- capture: | |
| 93 | + | return true | |
| 94 | + | default: | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |
| 100 | + | // without making session teardown wait behind its normal bounded queue. A | |
| 101 | + | // blocked worker retains at most one terminal capture per session, replacing | |
| 102 | + | // stale state with the newest immutable capture. | |
| 103 | + | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |
| 104 | + | if capture == nil || capture.session == nil { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | d.snapshotWorkerMu.Lock() | |
| 108 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 109 | + | d.snapshotWorkerMu.Unlock() | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | if d.snapshotFinalJobs == nil { | |
| 113 | + | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 114 | + | } | |
| 115 | + | replaced, exists := d.snapshotFinalJobs[capture.session] | |
| 116 | + | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |
| 117 | + | d.snapshotWorkerMu.Unlock() | |
| 118 | + | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | // Finish the stale capture before publishing its replacement. Otherwise a | |
| 122 | + | // fast worker could persist the replacement and then have this stale failure | |
| 123 | + | // mark the session dirty again. | |
| 124 | + | d.finishSnapshotCapture(replaced, false) | |
| 125 | + | if !exists { | |
| 126 | + | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |
| 127 | + | } | |
| 128 | + | d.snapshotFinalJobs[capture.session] = capture | |
| 129 | + | select { | |
| 130 | + | case d.snapshotWorkerFinalWake <- struct{}{}: | |
| 131 | + | default: | |
| 132 | + | } | |
| 133 | + | d.snapshotWorkerMu.Unlock() | |
| 134 | + | return true | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||
| 6 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | // scheduleFinalSnapshot captures the terminal state even when an older capture | |||
| 10 | + | // is pending. A removed session cannot be retried by the repository scheduler, so the | |||
| 11 | + | // worker drains its retained terminal state before it stops. | |||
| 12 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |||
| 13 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |||
| 17 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |||
| 18 | + | return true | |||
| 19 | + | } | |||
| 20 | + | kind := snapshotAttemptRoutine | |||
| 21 | + | if final { | |||
| 22 | + | kind = snapshotAttemptForced | |||
| 23 | + | } | |||
| 24 | + | sess.snapshotMu.Lock() | |||
| 25 | + | if sess.snapshotQuarantined || !sess.snapDirty.Load() { | |||
| 26 | + | available := !sess.snapshotQuarantined | |||
| 27 | + | sess.snapshotMu.Unlock() | |||
| 28 | + | return available | |||
| 29 | + | } | |||
| 30 | + | if final && sess.snapshotGeneration > sess.snapshotForcedGeneration { | |||
| 31 | + | // Do not let an older queued or in-flight routine publication satisfy a | |||
| 32 | + | // terminal checkpoint. The intent is retained until a publication at | |||
| 33 | + | // this generation (or newer) succeeds. | |||
| 34 | + | sess.snapshotForcedGeneration = sess.snapshotGeneration | |||
| 35 | + | } | |||
| 36 | + | if sess.snapshotPending || | |||
| 37 | + | (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) { | |||
| 38 | + | sess.snapshotMu.Unlock() | |||
| 39 | + | return true | |||
| 40 | + | } | |||
| 41 | + | if sess.snapshotPublicationContext == nil { | |||
| 42 | + | sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background()) | |||
| 43 | + | } | |||
| 44 | + | generation := sess.snapshotGeneration | |||
| 45 | + | publicationContext := sess.snapshotPublicationContext | |||
| 46 | + | sess.snapshotCapturedGeneration = generation | |||
| 47 | + | sess.snapshotAttempted = true | |||
| 48 | + | sess.snapshotAttemptKind = kind | |||
| 49 | + | sess.snapshotPendingCaptures++ | |||
| 50 | + | sess.snapshotPending = true | |||
| 51 | + | sess.signalSnapshotChangedLocked() | |||
| 52 | + | sess.snapshotMu.Unlock() | |||
| 53 | + | ||||
| 54 | + | capture, ok := d.captureSnapshotState(sess, generation) | |||
| 55 | + | if capture != nil { | |||
| 56 | + | capture.attemptKind = kind | |||
| 57 | + | capture.publicationContext = publicationContext | |||
| 58 | + | } | |||
| 59 | + | if !ok { | |||
| 60 | + | d.finishSnapshotCapture(capture, false) | |||
| 61 | + | return false | |||
| 62 | + | } | |||
| 63 | + | sess.snapshotMu.Lock() | |||
| 64 | + | quarantined := sess.snapshotQuarantined | |||
| 65 | + | if !quarantined { | |||
| 66 | + | sess.snapshotQueuedCapture = capture | |||
| 67 | + | } | |||
| 68 | + | sess.snapshotMu.Unlock() | |||
| 69 | + | if quarantined { | |||
| 70 | + | d.finishSnapshotCapture(capture, false) | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) { | |||
| 74 | + | return true | |||
| 75 | + | } | |||
| 76 | + | // Coalesce under saturation or shutdown: the latest state stays dirty and | |||
| 77 | + | // will be captured on a later tick once an active worker has room. | |||
| 78 | + | d.finishSnapshotCapture(capture, false) | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // enqueueSnapshotCapture serializes worker shutdown with the non-blocking | |||
| 83 | + | // queue send. The queue is deliberately never closed: producers can race | |||
| 84 | + | // shutdown without risking a send-on-closed panic. | |||
| 85 | + | func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool { | |||
| 86 | + | d.snapshotWorkerMu.Lock() | |||
| 87 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 88 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | select { | |||
| 92 | + | case d.snapshotJobs <- capture: | |||
| 93 | + | return true | |||
| 94 | + | default: | |||
| 95 | + | return false | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // enqueueFinalSnapshotCapture hands terminal captures to the existing worker | |||
| 100 | + | // without making session teardown wait behind its normal bounded queue. A | |||
| 101 | + | // blocked worker retains at most one terminal capture per session, replacing | |||
| 102 | + | // stale state with the newest immutable capture. | |||
| 103 | + | func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool { | |||
| 104 | + | if capture == nil || capture.session == nil { | |||
| 105 | + | return false | |||
| 106 | + | } | |||
| 107 | + | d.snapshotWorkerMu.Lock() | |||
| 108 | + | if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 109 | + | d.snapshotWorkerMu.Unlock() | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | if d.snapshotFinalJobs == nil { | |||
| 113 | + | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |||
| 114 | + | } | |||
| 115 | + | replaced, exists := d.snapshotFinalJobs[capture.session] | |||
| 116 | + | if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity { | |||
| 117 | + | d.snapshotWorkerMu.Unlock() | |||
| 118 | + | d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity) | |||
| 119 | + | return false | |||
| 120 | + | } | |||
| 121 | + | // Finish the stale capture before publishing its replacement. Otherwise a | |||
| 122 | + | // fast worker could persist the replacement and then have this stale failure | |||
| 123 | + | // mark the session dirty again. | |||
| 124 | + | d.finishSnapshotCapture(replaced, false) | |||
| 125 | + | if !exists { | |||
| 126 | + | d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session) | |||
| 127 | + | } | |||
| 128 | + | d.snapshotFinalJobs[capture.session] = capture | |||
| 129 | + | select { | |||
| 130 | + | case d.snapshotWorkerFinalWake <- struct{}{}: | |||
| 131 | + | default: | |||
| 132 | + | } | |||
| 133 | + | d.snapshotWorkerMu.Unlock() | |||
| 134 | + | return true | |||
| 135 | + | } | |||
M internal/usecase/daemon/snapshot_restore.go +170 -123
| @@ -45,10 +45,7 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||
| 45 | 45 | ||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | - | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |
| 49 | - | if err := ctx.Err(); err != nil { | |
| 50 | - | return err | |
| 51 | - | } | |
| 48 | + | func validateRestoreSessionSnapshot(snap snapcodec.Session) error { | |
| 52 | 49 | if len(snap.Tabs) == 0 { | |
| 53 | 50 | if snap.Active != 0 { | |
| 54 | 51 | return fmt.Errorf("snapshot: active tab out of range") | |
| @@ -62,127 +59,182 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 62 | 59 | if snap.CreatedAt > math.MaxInt64 { | |
| 63 | 60 | return fmt.Errorf("snapshot: created_at overflows int64") | |
| 64 | 61 | } | |
| 65 | - | d.mu.Lock() | |
| 66 | - | if d.closing || d.findByNameLocked(snap.Name) != nil { | |
| 67 | - | d.mu.Unlock() | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // restoreSession owns the restored tabs until registration succeeds. Every | |
| 66 | + | // unsuccessful path closes their PTYs and cancels their shared session context. | |
| 67 | + | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |
| 68 | + | if err := ctx.Err(); err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | if err := validateRestoreSessionSnapshot(snap); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if d.restoredSessionAlreadyExists(snap.Name) { | |
| 68 | 75 | return nil | |
| 69 | 76 | } | |
| 70 | - | d.mu.Unlock() | |
| 71 | 77 | ||
| 72 | 78 | parent := d.serveCtx | |
| 73 | 79 | if parent == nil { | |
| 74 | 80 | parent = context.Background() | |
| 75 | 81 | } | |
| 76 | 82 | sctx, cancel := context.WithCancel(parent) | |
| 77 | - | opened := make([]*tab, 0, len(snap.Tabs)) | |
| 78 | - | // Snapshot restore runs before any client Hello is available. Start panes | |
| 79 | - | // from the daemon environment captured at startup; the next attach replaces | |
| 80 | - | // this snapshot for future panes from the client's capability. | |
| 81 | - | restoreTerm := terminalEnv{} | |
| 82 | - | restoreEnv := copyEnvironment(d.baseEnv) | |
| 83 | - | closeOpened := func() { | |
| 84 | - | for _, tb := range opened { | |
| 85 | - | tb.closeAllPanes() | |
| 86 | - | } | |
| 83 | + | opened, err := d.restoreSnapshotTabs(ctx, sctx, snap) | |
| 84 | + | if err != nil { | |
| 87 | 85 | cancel() | |
| 86 | + | return err | |
| 88 | 87 | } | |
| 89 | - | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 88 | + | if len(opened) == 0 { | |
| 89 | + | cancel() | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ownsOpened := true | |
| 93 | + | defer func() { | |
| 94 | + | if ownsOpened { | |
| 95 | + | closeRestoredTabs(opened) | |
| 96 | + | cancel() | |
| 97 | + | } | |
| 98 | + | }() | |
| 99 | + | ||
| 100 | + | sess := d.newRestoredSession(snap, sctx, cancel, opened) | |
| 101 | + | registered, err := d.persistAndRegisterRestoredSession(ctx, sess) | |
| 102 | + | if err != nil || !registered { | |
| 103 | + | return err | |
| 104 | + | } | |
| 105 | + | ownsOpened = false | |
| 106 | + | for _, tb := range opened { | |
| 107 | + | d.startTabGoroutines(sess, tb) | |
| 108 | + | } | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (d *Daemon) restoredSessionAlreadyExists(name string) bool { | |
| 90 | 113 | d.mu.Lock() | |
| 91 | - | persisted := d.stopped[snap.Name] | |
| 92 | - | d.mu.Unlock() | |
| 93 | - | for tabIndex, tabSnap := range snap.Tabs { | |
| 114 | + | defer d.mu.Unlock() | |
| 115 | + | return d.closing || d.findByNameLocked(name) != nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | |
| 119 | + | // Restore runs before client Hello. Future panes therefore inherit the | |
| 120 | + | // daemon's startup environment until an attach supplies terminal capability. | |
| 121 | + | restoreTerm := terminalEnv{} | |
| 122 | + | restoreEnv := copyEnvironment(d.baseEnv) | |
| 123 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 124 | + | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | |
| 125 | + | opened := make([]*tab, 0, len(snap.Tabs)) | |
| 126 | + | for index, tabSnap := range snap.Tabs { | |
| 94 | 127 | if err := ctx.Err(); err != nil { | |
| 95 | - | closeOpened() | |
| 96 | - | return err | |
| 128 | + | closeRestoredTabs(opened) | |
| 129 | + | return nil, err | |
| 97 | 130 | } | |
| 98 | - | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |
| 99 | - | if !tbSize.Valid() { | |
| 100 | - | closeOpened() | |
| 101 | - | return fmt.Errorf("snapshot: invalid tab size") | |
| 131 | + | tabName := "" | |
| 132 | + | if index < len(stoppedTabNames) { | |
| 133 | + | tabName = stoppedTabNames[index] | |
| 102 | 134 | } | |
| 103 | - | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |
| 104 | - | if !ok { | |
| 105 | - | closeOpened() | |
| 106 | - | return fmt.Errorf("snapshot: unsolvable layout") | |
| 135 | + | tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist) | |
| 136 | + | if err != nil { | |
| 137 | + | closeRestoredTabs(opened) | |
| 138 | + | return nil, err | |
| 107 | 139 | } | |
| 108 | - | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |
| 109 | - | for _, pl := range placements { | |
| 110 | - | placementByPane[pl.ID] = pl.Content | |
| 140 | + | opened = append(opened, tb) | |
| 141 | + | } | |
| 142 | + | return opened, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // closeRestoredTabs releases every PTY and pane context still owned by a | |
| 146 | + | // failed restore. Registered sessions transfer this ownership to the daemon. | |
| 147 | + | func closeRestoredTabs(tabs []*tab) { | |
| 148 | + | for _, tb := range tabs { | |
| 149 | + | tb.closeAllPanes() | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (d *Daemon) restoredSessionTabNames(name string) []string { | |
| 154 | + | d.mu.Lock() | |
| 155 | + | defer d.mu.Unlock() | |
| 156 | + | return append([]string(nil), d.stopped[name].tabNames...) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) { | |
| 160 | + | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |
| 161 | + | if !tbSize.Valid() { | |
| 162 | + | return nil, fmt.Errorf("snapshot: invalid tab size") | |
| 163 | + | } | |
| 164 | + | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |
| 165 | + | if !ok { | |
| 166 | + | return nil, fmt.Errorf("snapshot: unsolvable layout") | |
| 167 | + | } | |
| 168 | + | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |
| 169 | + | for _, placement := range placements { | |
| 170 | + | placementByPane[placement.ID] = placement.Content | |
| 171 | + | } | |
| 172 | + | tabStableID := tabSnap.StableID | |
| 173 | + | if tabStableID == "" { | |
| 174 | + | var err error | |
| 175 | + | tabStableID, err = newStableID("t") | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, fmt.Errorf("snapshot: generating tab identity: %w", err) | |
| 111 | 178 | } | |
| 112 | - | tabStableID := tabSnap.StableID | |
| 113 | - | if tabStableID == "" { | |
| 114 | - | var err error | |
| 115 | - | tabStableID, err = newStableID("t") | |
| 116 | - | if err != nil { | |
| 117 | - | closeOpened() | |
| 118 | - | return fmt.Errorf("snapshot: generating tab identity: %w", err) | |
| 119 | - | } | |
| 179 | + | } | |
| 180 | + | tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 181 | + | if tb.nextPaneID <= 0 { | |
| 182 | + | tb.nextPaneID = 1 | |
| 183 | + | } | |
| 184 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 185 | + | for _, paneSnap := range tabSnap.Panes { | |
| 186 | + | if err := ctx.Err(); err != nil { | |
| 187 | + | tb.closeAllPanes() | |
| 188 | + | return nil, err | |
| 120 | 189 | } | |
| 121 | - | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 122 | - | if tabIndex < len(persisted.tabNames) { | |
| 123 | - | tb.name = persisted.tabNames[tabIndex] | |
| 190 | + | contentRect, ok := placementByPane[paneSnap.ID] | |
| 191 | + | if !ok { | |
| 192 | + | tb.closeAllPanes() | |
| 193 | + | return nil, fmt.Errorf("snapshot: missing pane placement") | |
| 124 | 194 | } | |
| 125 | - | if tb.nextPaneID <= 0 { | |
| 126 | - | tb.nextPaneID = 1 | |
| 195 | + | p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx) | |
| 196 | + | if err != nil { | |
| 197 | + | tb.closeAllPanes() | |
| 198 | + | return nil, err | |
| 127 | 199 | } | |
| 128 | - | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 129 | - | opened = append(opened, tb) | |
| 130 | - | for _, paneSnap := range tabSnap.Panes { | |
| 131 | - | restoreCommand := "" | |
| 132 | - | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 133 | - | restoreCommand = decision.Command | |
| 134 | - | } | |
| 135 | - | if err := ctx.Err(); err != nil { | |
| 136 | - | closeOpened() | |
| 137 | - | return err | |
| 138 | - | } | |
| 139 | - | contentRect, ok := placementByPane[paneSnap.ID] | |
| 140 | - | if !ok { | |
| 141 | - | closeOpened() | |
| 142 | - | return fmt.Errorf("snapshot: missing pane placement") | |
| 143 | - | } | |
| 144 | - | contentSize := restorePTYSize(contentRect, tbSize) | |
| 145 | - | paneStableID := paneSnap.StableID | |
| 146 | - | if paneStableID == "" { | |
| 147 | - | var err error | |
| 148 | - | paneStableID, err = newStableID("p") | |
| 149 | - | if err != nil { | |
| 150 | - | closeOpened() | |
| 151 | - | return fmt.Errorf("snapshot: generating pane identity: %w", err) | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | command, args := d.ptyCommand(restoreEnv) | |
| 155 | - | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize) | |
| 156 | - | if err != nil { | |
| 157 | - | closeOpened() | |
| 158 | - | return err | |
| 159 | - | } | |
| 160 | - | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |
| 161 | - | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 162 | - | p.rect = contentRect | |
| 163 | - | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 164 | - | // p is not in tb.panes yet, so closeOpened cannot reach it. | |
| 165 | - | p.cancel() | |
| 166 | - | _ = pty.Close() | |
| 167 | - | closeOpened() | |
| 168 | - | return err | |
| 169 | - | } | |
| 170 | - | if restoreCommand != "" { | |
| 171 | - | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 172 | - | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| 173 | - | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |
| 174 | - | "couldn't restore the running program in session "+snap.Name, err) | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | tb.panes[paneSnap.ID] = p | |
| 200 | + | tb.panes[paneSnap.ID] = p | |
| 201 | + | } | |
| 202 | + | return tb, nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) { | |
| 206 | + | paneStableID := paneSnap.StableID | |
| 207 | + | if paneStableID == "" { | |
| 208 | + | var err error | |
| 209 | + | paneStableID, err = newStableID("p") | |
| 210 | + | if err != nil { | |
| 211 | + | return nil, fmt.Errorf("snapshot: generating pane identity: %w", err) | |
| 178 | 212 | } | |
| 179 | 213 | } | |
| 180 | - | if len(opened) == 0 { | |
| 181 | - | cancel() | |
| 182 | - | return nil | |
| 214 | + | command, args := d.ptyCommand(restoreEnv) | |
| 215 | + | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize)) | |
| 216 | + | if err != nil { | |
| 217 | + | return nil, err | |
| 218 | + | } | |
| 219 | + | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize)) | |
| 220 | + | p.ctx, p.cancel = context.WithCancel(tabCtx) | |
| 221 | + | p.rect = contentRect | |
| 222 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { | |
| 223 | + | p.cancel() | |
| 224 | + | _ = pty.Close() | |
| 225 | + | return nil, err | |
| 226 | + | } | |
| 227 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 228 | + | if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil { | |
| 229 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID) | |
| 230 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err) | |
| 231 | + | } | |
| 183 | 232 | } | |
| 184 | - | createdAt := int64(snap.CreatedAt) | |
| 185 | - | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 233 | + | return p, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 237 | + | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 186 | 238 | sess.snapEligible.Store(true) | |
| 187 | 239 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 188 | 240 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| @@ -192,33 +244,28 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 192 | 244 | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 193 | 245 | } | |
| 194 | 246 | d.mu.Unlock() | |
| 195 | - | record := sess.persistRecordLocked(createdAt) | |
| 196 | - | if err := d.persist.Save(record); err != nil { | |
| 197 | - | closeOpened() | |
| 198 | - | return err | |
| 247 | + | return sess | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // persistAndRegisterRestoredSession atomically transfers a successfully | |
| 251 | + | // persisted session to the daemon. The caller retains cleanup on false/error. | |
| 252 | + | func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) { | |
| 253 | + | if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil { | |
| 254 | + | return false, err | |
| 199 | 255 | } | |
| 200 | 256 | d.mu.Lock() | |
| 201 | - | if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil { | |
| 202 | - | d.mu.Unlock() | |
| 203 | - | closeOpened() | |
| 204 | - | return nil | |
| 257 | + | defer d.mu.Unlock() | |
| 258 | + | if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil { | |
| 259 | + | return false, nil | |
| 205 | 260 | } | |
| 206 | - | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 261 | + | sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 207 | 262 | d.nextID++ | |
| 208 | - | sess.id = id | |
| 209 | - | delete(d.stopped, snap.Name) | |
| 210 | - | d.sessions[id] = sess | |
| 211 | - | d.mu.Unlock() | |
| 263 | + | delete(d.stopped, sess.name) | |
| 264 | + | d.sessions[sess.id] = sess | |
| 212 | 265 | sess.snapDirty.Store(false) | |
| 213 | - | for _, tb := range opened { | |
| 214 | - | d.startTabGoroutines(sess, tb) | |
| 215 | - | } | |
| 216 | - | return nil | |
| 266 | + | return true, nil | |
| 217 | 267 | } | |
| 218 | 268 | ||
| 219 | - | // snapshotCoordinatorContext returns the cancellation context shared by this | |
| 220 | - | // session's queued and in-flight captures. snapshotMu serializes creation with | |
| 221 | - | ||
| 222 | 269 | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |
| 223 | 270 | if len(snap.Tail) == 0 { | |
| 224 | 271 | return fmt.Errorf("snapshot history: missing tail blob") | |
| @@ -45,10 +45,7 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||||
| 45 | 45 | ||||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | - | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | 48 | + | func validateRestoreSessionSnapshot(snap snapcodec.Session) error { |
| 49 | - | if err := ctx.Err(); err != nil { | |||
| 50 | - | return err | |||
| 51 | - | } | |||
| 52 | if len(snap.Tabs) == 0 { | 49 | if len(snap.Tabs) == 0 { | ||
| 53 | if snap.Active != 0 { | 50 | if snap.Active != 0 { | ||
| 54 | return fmt.Errorf("snapshot: active tab out of range") | 51 | return fmt.Errorf("snapshot: active tab out of range") | ||
| @@ -62,127 +59,182 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 62 | if snap.CreatedAt > math.MaxInt64 { | 59 | if snap.CreatedAt > math.MaxInt64 { | ||
| 63 | return fmt.Errorf("snapshot: created_at overflows int64") | 60 | return fmt.Errorf("snapshot: created_at overflows int64") | ||
| 64 | } | 61 | } | ||
| 65 | - | d.mu.Lock() | 62 | + | return nil |
| 66 | - | if d.closing || d.findByNameLocked(snap.Name) != nil { | 63 | + | } |
| 67 | - | d.mu.Unlock() | 64 | + | |
| 65 | + | // restoreSession owns the restored tabs until registration succeeds. Every | |||
| 66 | + | // unsuccessful path closes their PTYs and cancels their shared session context. | |||
| 67 | + | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |||
| 68 | + | if err := ctx.Err(); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | if err := validateRestoreSessionSnapshot(snap); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | if d.restoredSessionAlreadyExists(snap.Name) { | |||
| 68 | return nil | 75 | return nil | ||
| 69 | } | 76 | } | ||
| 70 | - | d.mu.Unlock() | |||
| 71 | 77 | ||||
| 72 | parent := d.serveCtx | 78 | parent := d.serveCtx | ||
| 73 | if parent == nil { | 79 | if parent == nil { | ||
| 74 | parent = context.Background() | 80 | parent = context.Background() | ||
| 75 | } | 81 | } | ||
| 76 | sctx, cancel := context.WithCancel(parent) | 82 | sctx, cancel := context.WithCancel(parent) | ||
| 77 | - | opened := make([]*tab, 0, len(snap.Tabs)) | 83 | + | opened, err := d.restoreSnapshotTabs(ctx, sctx, snap) |
| 78 | - | // Snapshot restore runs before any client Hello is available. Start panes | 84 | + | if err != nil { |
| 79 | - | // from the daemon environment captured at startup; the next attach replaces | |||
| 80 | - | // this snapshot for future panes from the client's capability. | |||
| 81 | - | restoreTerm := terminalEnv{} | |||
| 82 | - | restoreEnv := copyEnvironment(d.baseEnv) | |||
| 83 | - | closeOpened := func() { | |||
| 84 | - | for _, tb := range opened { | |||
| 85 | - | tb.closeAllPanes() | |||
| 86 | - | } | |||
| 87 | cancel() | 85 | cancel() | ||
| 86 | + | return err | |||
| 88 | } | 87 | } | ||
| 89 | - | allowlist := d.restoreProcessAllowlistSnapshot() | 88 | + | if len(opened) == 0 { |
| 89 | + | cancel() | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ownsOpened := true | |||
| 93 | + | defer func() { | |||
| 94 | + | if ownsOpened { | |||
| 95 | + | closeRestoredTabs(opened) | |||
| 96 | + | cancel() | |||
| 97 | + | } | |||
| 98 | + | }() | |||
| 99 | + | ||||
| 100 | + | sess := d.newRestoredSession(snap, sctx, cancel, opened) | |||
| 101 | + | registered, err := d.persistAndRegisterRestoredSession(ctx, sess) | |||
| 102 | + | if err != nil || !registered { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | ownsOpened = false | |||
| 106 | + | for _, tb := range opened { | |||
| 107 | + | d.startTabGoroutines(sess, tb) | |||
| 108 | + | } | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (d *Daemon) restoredSessionAlreadyExists(name string) bool { | |||
| 90 | d.mu.Lock() | 113 | d.mu.Lock() | ||
| 91 | - | persisted := d.stopped[snap.Name] | 114 | + | defer d.mu.Unlock() |
| 92 | - | d.mu.Unlock() | 115 | + | return d.closing || d.findByNameLocked(name) != nil |
| 93 | - | for tabIndex, tabSnap := range snap.Tabs { | 116 | + | } |
| 117 | + | ||||
| 118 | + | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | |||
| 119 | + | // Restore runs before client Hello. Future panes therefore inherit the | |||
| 120 | + | // daemon's startup environment until an attach supplies terminal capability. | |||
| 121 | + | restoreTerm := terminalEnv{} | |||
| 122 | + | restoreEnv := copyEnvironment(d.baseEnv) | |||
| 123 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |||
| 124 | + | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | |||
| 125 | + | opened := make([]*tab, 0, len(snap.Tabs)) | |||
| 126 | + | for index, tabSnap := range snap.Tabs { | |||
| 94 | if err := ctx.Err(); err != nil { | 127 | if err := ctx.Err(); err != nil { | ||
| 95 | - | closeOpened() | 128 | + | closeRestoredTabs(opened) |
| 96 | - | return err | 129 | + | return nil, err |
| 97 | } | 130 | } | ||
| 98 | - | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | 131 | + | tabName := "" |
| 99 | - | if !tbSize.Valid() { | 132 | + | if index < len(stoppedTabNames) { |
| 100 | - | closeOpened() | 133 | + | tabName = stoppedTabNames[index] |
| 101 | - | return fmt.Errorf("snapshot: invalid tab size") | |||
| 102 | } | 134 | } | ||
| 103 | - | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | 135 | + | tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist) |
| 104 | - | if !ok { | 136 | + | if err != nil { |
| 105 | - | closeOpened() | 137 | + | closeRestoredTabs(opened) |
| 106 | - | return fmt.Errorf("snapshot: unsolvable layout") | 138 | + | return nil, err |
| 107 | } | 139 | } | ||
| 108 | - | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | 140 | + | opened = append(opened, tb) |
| 109 | - | for _, pl := range placements { | 141 | + | } |
| 110 | - | placementByPane[pl.ID] = pl.Content | 142 | + | return opened, nil |
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // closeRestoredTabs releases every PTY and pane context still owned by a | |||
| 146 | + | // failed restore. Registered sessions transfer this ownership to the daemon. | |||
| 147 | + | func closeRestoredTabs(tabs []*tab) { | |||
| 148 | + | for _, tb := range tabs { | |||
| 149 | + | tb.closeAllPanes() | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (d *Daemon) restoredSessionTabNames(name string) []string { | |||
| 154 | + | d.mu.Lock() | |||
| 155 | + | defer d.mu.Unlock() | |||
| 156 | + | return append([]string(nil), d.stopped[name].tabNames...) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) { | |||
| 160 | + | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |||
| 161 | + | if !tbSize.Valid() { | |||
| 162 | + | return nil, fmt.Errorf("snapshot: invalid tab size") | |||
| 163 | + | } | |||
| 164 | + | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |||
| 165 | + | if !ok { | |||
| 166 | + | return nil, fmt.Errorf("snapshot: unsolvable layout") | |||
| 167 | + | } | |||
| 168 | + | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |||
| 169 | + | for _, placement := range placements { | |||
| 170 | + | placementByPane[placement.ID] = placement.Content | |||
| 171 | + | } | |||
| 172 | + | tabStableID := tabSnap.StableID | |||
| 173 | + | if tabStableID == "" { | |||
| 174 | + | var err error | |||
| 175 | + | tabStableID, err = newStableID("t") | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, fmt.Errorf("snapshot: generating tab identity: %w", err) | |||
| 111 | } | 178 | } | ||
| 112 | - | tabStableID := tabSnap.StableID | 179 | + | } |
| 113 | - | if tabStableID == "" { | 180 | + | tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} |
| 114 | - | var err error | 181 | + | if tb.nextPaneID <= 0 { |
| 115 | - | tabStableID, err = newStableID("t") | 182 | + | tb.nextPaneID = 1 |
| 116 | - | if err != nil { | 183 | + | } |
| 117 | - | closeOpened() | 184 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) |
| 118 | - | return fmt.Errorf("snapshot: generating tab identity: %w", err) | 185 | + | for _, paneSnap := range tabSnap.Panes { |
| 119 | - | } | 186 | + | if err := ctx.Err(); err != nil { |
| 187 | + | tb.closeAllPanes() | |||
| 188 | + | return nil, err | |||
| 120 | } | 189 | } | ||
| 121 | - | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | 190 | + | contentRect, ok := placementByPane[paneSnap.ID] |
| 122 | - | if tabIndex < len(persisted.tabNames) { | 191 | + | if !ok { |
| 123 | - | tb.name = persisted.tabNames[tabIndex] | 192 | + | tb.closeAllPanes() |
| 193 | + | return nil, fmt.Errorf("snapshot: missing pane placement") | |||
| 124 | } | 194 | } | ||
| 125 | - | if tb.nextPaneID <= 0 { | 195 | + | p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx) |
| 126 | - | tb.nextPaneID = 1 | 196 | + | if err != nil { |
| 197 | + | tb.closeAllPanes() | |||
| 198 | + | return nil, err | |||
| 127 | } | 199 | } | ||
| 128 | - | tb.ctx, tb.cancel = context.WithCancel(sctx) | 200 | + | tb.panes[paneSnap.ID] = p |
| 129 | - | opened = append(opened, tb) | 201 | + | } |
| 130 | - | for _, paneSnap := range tabSnap.Panes { | 202 | + | return tb, nil |
| 131 | - | restoreCommand := "" | 203 | + | } |
| 132 | - | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | 204 | + | |
| 133 | - | restoreCommand = decision.Command | 205 | + | func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) { |
| 134 | - | } | 206 | + | paneStableID := paneSnap.StableID |
| 135 | - | if err := ctx.Err(); err != nil { | 207 | + | if paneStableID == "" { |
| 136 | - | closeOpened() | 208 | + | var err error |
| 137 | - | return err | 209 | + | paneStableID, err = newStableID("p") |
| 138 | - | } | 210 | + | if err != nil { |
| 139 | - | contentRect, ok := placementByPane[paneSnap.ID] | 211 | + | return nil, fmt.Errorf("snapshot: generating pane identity: %w", err) |
| 140 | - | if !ok { | |||
| 141 | - | closeOpened() | |||
| 142 | - | return fmt.Errorf("snapshot: missing pane placement") | |||
| 143 | - | } | |||
| 144 | - | contentSize := restorePTYSize(contentRect, tbSize) | |||
| 145 | - | paneStableID := paneSnap.StableID | |||
| 146 | - | if paneStableID == "" { | |||
| 147 | - | var err error | |||
| 148 | - | paneStableID, err = newStableID("p") | |||
| 149 | - | if err != nil { | |||
| 150 | - | closeOpened() | |||
| 151 | - | return fmt.Errorf("snapshot: generating pane identity: %w", err) | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | command, args := d.ptyCommand(restoreEnv) | |||
| 155 | - | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize) | |||
| 156 | - | if err != nil { | |||
| 157 | - | closeOpened() | |||
| 158 | - | return err | |||
| 159 | - | } | |||
| 160 | - | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |||
| 161 | - | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |||
| 162 | - | p.rect = contentRect | |||
| 163 | - | if err := restorePaneTerminal(p, paneSnap); err != nil { | |||
| 164 | - | // p is not in tb.panes yet, so closeOpened cannot reach it. | |||
| 165 | - | p.cancel() | |||
| 166 | - | _ = pty.Close() | |||
| 167 | - | closeOpened() | |||
| 168 | - | return err | |||
| 169 | - | } | |||
| 170 | - | if restoreCommand != "" { | |||
| 171 | - | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |||
| 172 | - | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |||
| 173 | - | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, | |||
| 174 | - | "couldn't restore the running program in session "+snap.Name, err) | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | tb.panes[paneSnap.ID] = p | |||
| 178 | } | 212 | } | ||
| 179 | } | 213 | } | ||
| 180 | - | if len(opened) == 0 { | 214 | + | command, args := d.ptyCommand(restoreEnv) |
| 181 | - | cancel() | 215 | + | pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize)) |
| 182 | - | return nil | 216 | + | if err != nil { |
| 217 | + | return nil, err | |||
| 218 | + | } | |||
| 219 | + | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize)) | |||
| 220 | + | p.ctx, p.cancel = context.WithCancel(tabCtx) | |||
| 221 | + | p.rect = contentRect | |||
| 222 | + | if err := restorePaneTerminal(p, paneSnap); err != nil { | |||
| 223 | + | p.cancel() | |||
| 224 | + | _ = pty.Close() | |||
| 225 | + | return nil, err | |||
| 226 | + | } | |||
| 227 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |||
| 228 | + | if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil { | |||
| 229 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID) | |||
| 230 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err) | |||
| 231 | + | } | |||
| 183 | } | 232 | } | ||
| 184 | - | createdAt := int64(snap.CreatedAt) | 233 | + | return p, nil |
| 185 | - | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | 234 | + | } |
| 235 | + | ||||
| 236 | + | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |||
| 237 | + | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |||
| 186 | sess.snapEligible.Store(true) | 238 | sess.snapEligible.Store(true) | ||
| 187 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | 239 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | ||
| 188 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | 240 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | ||
| @@ -192,33 +244,28 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 192 | sess.mruAt.Store(stopped.lastUsedSeq) | 244 | sess.mruAt.Store(stopped.lastUsedSeq) | ||
| 193 | } | 245 | } | ||
| 194 | d.mu.Unlock() | 246 | d.mu.Unlock() | ||
| 195 | - | record := sess.persistRecordLocked(createdAt) | 247 | + | return sess |
| 196 | - | if err := d.persist.Save(record); err != nil { | 248 | + | } |
| 197 | - | closeOpened() | 249 | + | |
| 198 | - | return err | 250 | + | // persistAndRegisterRestoredSession atomically transfers a successfully |
| 251 | + | // persisted session to the daemon. The caller retains cleanup on false/error. | |||
| 252 | + | func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) { | |||
| 253 | + | if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil { | |||
| 254 | + | return false, err | |||
| 199 | } | 255 | } | ||
| 200 | d.mu.Lock() | 256 | d.mu.Lock() | ||
| 201 | - | if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil { | 257 | + | defer d.mu.Unlock() |
| 202 | - | d.mu.Unlock() | 258 | + | if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil { |
| 203 | - | closeOpened() | 259 | + | return false, nil |
| 204 | - | return nil | |||
| 205 | } | 260 | } | ||
| 206 | - | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 261 | + | sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) |
| 207 | d.nextID++ | 262 | d.nextID++ | ||
| 208 | - | sess.id = id | 263 | + | delete(d.stopped, sess.name) |
| 209 | - | delete(d.stopped, snap.Name) | 264 | + | d.sessions[sess.id] = sess |
| 210 | - | d.sessions[id] = sess | |||
| 211 | - | d.mu.Unlock() | |||
| 212 | sess.snapDirty.Store(false) | 265 | sess.snapDirty.Store(false) | ||
| 213 | - | for _, tb := range opened { | 266 | + | return true, nil |
| 214 | - | d.startTabGoroutines(sess, tb) | |||
| 215 | - | } | |||
| 216 | - | return nil | |||
| 217 | } | 267 | } | ||
| 218 | 268 | ||||
| 219 | - | // snapshotCoordinatorContext returns the cancellation context shared by this | |||
| 220 | - | // session's queued and in-flight captures. snapshotMu serializes creation with | |||
| 221 | - | ||||
| 222 | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | 269 | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | ||
| 223 | if len(snap.Tail) == 0 { | 270 | if len(snap.Tail) == 0 { | ||
| 224 | return fmt.Errorf("snapshot history: missing tail blob") | 271 | return fmt.Errorf("snapshot history: missing tail blob") | ||
M internal/usecase/daemon/snapshot_restore_test.go +24 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "math" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| @@ -155,6 +156,29 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||
| 155 | 156 | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | |
| 156 | 157 | } | |
| 157 | 158 | ||
| 159 | + | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |
| 160 | + | valid := restoreAcceptanceSession(t, "restored") | |
| 161 | + | for _, test := range []struct { | |
| 162 | + | name string | |
| 163 | + | snap snapcodec.Session | |
| 164 | + | }{ | |
| 165 | + | {"valid", valid}, | |
| 166 | + | {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()}, | |
| 167 | + | {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()}, | |
| 168 | + | {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()}, | |
| 169 | + | {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()}, | |
| 170 | + | } { | |
| 171 | + | t.Run(test.name, func(t *testing.T) { | |
| 172 | + | err := validateRestoreSessionSnapshot(test.snap) | |
| 173 | + | if test.name == "valid" { | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | require.Error(t, err) | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 158 | 182 | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |
| 159 | 183 | snapshot := restoreAcceptanceSession(t, "restored") | |
| 160 | 184 | generation := acceptanceGeneration(t, snapshot, 9) | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "math" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| @@ -155,6 +156,29 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||||
| 155 | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | 156 | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | ||
| 156 | } | 157 | } | ||
| 157 | 158 | ||||
| 159 | + | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |||
| 160 | + | valid := restoreAcceptanceSession(t, "restored") | |||
| 161 | + | for _, test := range []struct { | |||
| 162 | + | name string | |||
| 163 | + | snap snapcodec.Session | |||
| 164 | + | }{ | |||
| 165 | + | {"valid", valid}, | |||
| 166 | + | {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()}, | |||
| 167 | + | {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()}, | |||
| 168 | + | {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()}, | |||
| 169 | + | {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()}, | |||
| 170 | + | } { | |||
| 171 | + | t.Run(test.name, func(t *testing.T) { | |||
| 172 | + | err := validateRestoreSessionSnapshot(test.snap) | |||
| 173 | + | if test.name == "valid" { | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | require.Error(t, err) | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 158 | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | 182 | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | ||
| 159 | snapshot := restoreAcceptanceSession(t, "restored") | 183 | snapshot := restoreAcceptanceSession(t, "restored") | ||
| 160 | generation := acceptanceGeneration(t, snapshot, 9) | 184 | generation := acceptanceGeneration(t, snapshot, 9) | ||