refactor(daemon): picker actions return errors and surface notices
switchToTarget, resumeStoppedAndSwitchLocked, and killPickerTarget now return error instead of a bare bool/void so a failed restore or delete surfaces as a NoticeSessionUnavailable/NoticePersistDelete toast via handlePickerInput's reportError funnel, instead of only a log line.
- commit
- 9453c6dc412babdf2ae428f56398584f9bbd8286
- author
- brice · · 2026-07-20 18:05 UTC
- parent
- cd08227
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +116 -25
M internal/usecase/daemon/attention.go +1 -1
| @@ -58,7 +58,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | |||
| 58 | 58 | if !ok { | |
| 59 | 59 | return | |
| 60 | 60 | } | |
| 61 | - | d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 61 | + | _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 64 | func oldestAttentionTab(sess *session) (int, bool) { | |
| @@ -58,7 +58,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | |||||
| 58 | if !ok { | 58 | if !ok { | ||
| 59 | return | 59 | return | ||
| 60 | } | 60 | } | ||
| 61 | - | d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | 61 | + | _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) |
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | func oldestAttentionTab(sess *session) (int, bool) { | 64 | func oldestAttentionTab(sess *session) (int, bool) { | ||
M internal/usecase/daemon/palette.go +2 -2
| @@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 270 | 270 | } else { | |
| 271 | 271 | target.Stopped = true | |
| 272 | 272 | } | |
| 273 | - | if !d.switchToTarget(sess, ac, target) { | |
| 273 | + | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 274 | 274 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | |
| 275 | 275 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 276 | 276 | return | |
| @@ -510,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 510 | 510 | if e.d.beforeRecentSessionHandoff != nil { | |
| 511 | 511 | e.d.beforeRecentSessionHandoff() | |
| 512 | 512 | } | |
| 513 | - | if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) { | |
| 513 | + | if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { | |
| 514 | 514 | return command.ErrInvalidArguments | |
| 515 | 515 | } | |
| 516 | 516 | return nil | |
| @@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 270 | } else { | 270 | } else { | ||
| 271 | target.Stopped = true | 271 | target.Stopped = true | ||
| 272 | } | 272 | } | ||
| 273 | - | if !d.switchToTarget(sess, ac, target) { | 273 | + | if err := d.switchToTarget(sess, ac, target); err != nil { |
| 274 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | 274 | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | ||
| 275 | d.invalidateRender(sess, ac, true, "palette.go") | 275 | d.invalidateRender(sess, ac, true, "palette.go") | ||
| 276 | return | 276 | return | ||
| @@ -510,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 510 | if e.d.beforeRecentSessionHandoff != nil { | 510 | if e.d.beforeRecentSessionHandoff != nil { | ||
| 511 | e.d.beforeRecentSessionHandoff() | 511 | e.d.beforeRecentSessionHandoff() | ||
| 512 | } | 512 | } | ||
| 513 | - | if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) { | 513 | + | if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { |
| 514 | return command.ErrInvalidArguments | 514 | return command.ErrInvalidArguments | ||
| 515 | } | 515 | } | ||
| 516 | return nil | 516 | return nil | ||
M internal/usecase/daemon/picker.go +20 -14
| @@ -122,7 +122,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 122 | 122 | target, ok := ac.overlays.picker.Selected() | |
| 123 | 123 | ac.overlays.pickerMu.Unlock() | |
| 124 | 124 | if ok { | |
| 125 | - | d.killPickerTarget(target) | |
| 125 | + | if err := d.killPickerTarget(target); err != nil { | |
| 126 | + | d.reportError(sess, err) | |
| 127 | + | } | |
| 126 | 128 | } | |
| 127 | 129 | d.refreshPicker(ac) | |
| 128 | 130 | d.invalidateRender(sess, ac, true, "picker.go") | |
| @@ -171,7 +173,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 171 | 173 | d.closePicker(ac) | |
| 172 | 174 | } | |
| 173 | 175 | if switchTarget && ok { | |
| 174 | - | d.switchToTarget(sess, ac, target) | |
| 176 | + | if err := d.switchToTarget(sess, ac, target); err != nil { | |
| 177 | + | d.reportError(sess, err) | |
| 178 | + | } | |
| 175 | 179 | return | |
| 176 | 180 | } | |
| 177 | 181 | if exit || changed { | |
| @@ -394,19 +398,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 394 | 398 | // switchToTarget resolves named lifecycle targets and commits their transition | |
| 395 | 399 | // while d.mu is held. A named palette result is allowed to cross an | |
| 396 | 400 | // active/stopped transition, but it never follows a same-name replacement. | |
| 397 | - | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { | |
| 401 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { | |
| 398 | 402 | d.mu.Lock() | |
| 399 | 403 | var ( | |
| 400 | 404 | targetSess *session | |
| 401 | 405 | old *attachedClient | |
| 402 | 406 | cleanups []renderLifecycleCleanup | |
| 403 | 407 | switched bool | |
| 408 | + | cause error | |
| 404 | 409 | ) | |
| 405 | 410 | if target.Name != "" { | |
| 406 | 411 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 407 | 412 | if ok { | |
| 408 | 413 | if isStopped { | |
| 409 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 414 | + | targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 410 | 415 | } else { | |
| 411 | 416 | // The snapshot ID may describe the stopped representation. The | |
| 412 | 417 | // locked name/lifecycle resolver chose this active incarnation. | |
| @@ -422,7 +427,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 422 | 427 | ||
| 423 | 428 | if !switched { | |
| 424 | 429 | d.invalidateRender(from, ac, true, "picker.go") | |
| 425 | - | return false | |
| 430 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 426 | 431 | } | |
| 427 | 432 | finishRenderLifecycleCleanups(cleanups) | |
| 428 | 433 | if old != nil && old != ac { | |
| @@ -437,7 +442,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 437 | 442 | } else { | |
| 438 | 443 | d.firstPaint(targetSess, ac, ac.size) | |
| 439 | 444 | } | |
| 440 | - | return true | |
| 445 | + | return nil | |
| 441 | 446 | } | |
| 442 | 447 | ||
| 443 | 448 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | |
| @@ -517,11 +522,11 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 517 | 522 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 518 | 523 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 519 | 524 | // and stopped record untouched. | |
| 520 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { | |
| 525 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | |
| 521 | 526 | from.mu.Lock() | |
| 522 | 527 | if from.client != ac { | |
| 523 | 528 | from.mu.Unlock() | |
| 524 | - | return nil, nil, false | |
| 529 | + | return nil, nil, false, nil | |
| 525 | 530 | } | |
| 526 | 531 | term := from.terminal | |
| 527 | 532 | env := copyEnvironment(from.env) | |
| @@ -530,7 +535,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 530 | 535 | if err != nil { | |
| 531 | 536 | from.mu.Unlock() | |
| 532 | 537 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 533 | - | return nil, nil, false | |
| 538 | + | return nil, nil, false, err | |
| 534 | 539 | } | |
| 535 | 540 | from.client = nil | |
| 536 | 541 | ac.setSession(nil) | |
| @@ -543,7 +548,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 543 | 548 | targetSess.mu.Unlock() | |
| 544 | 549 | d.touchMRU(targetSess) | |
| 545 | 550 | ac.recordPreviousSession(from) | |
| 546 | - | return targetSess, cleanups, true | |
| 551 | + | return targetSess, cleanups, true, nil | |
| 547 | 552 | } | |
| 548 | 553 | ||
| 549 | 554 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| @@ -574,7 +579,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 574 | 579 | switched bool | |
| 575 | 580 | ) | |
| 576 | 581 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 577 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 582 | + | targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 578 | 583 | } | |
| 579 | 584 | d.mu.Unlock() | |
| 580 | 585 | if !switched { | |
| @@ -586,7 +591,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 586 | 591 | return true | |
| 587 | 592 | } | |
| 588 | 593 | ||
| 589 | - | func (d *Daemon) killPickerTarget(target picker.Target) { | |
| 594 | + | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
| 590 | 595 | if target.Stopped { | |
| 591 | 596 | d.mu.Lock() | |
| 592 | 597 | stopped, ok := d.stopped[target.Name] | |
| @@ -594,12 +599,12 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 594 | 599 | if err := d.persist.Delete(target.Name); err != nil { | |
| 595 | 600 | d.mu.Unlock() | |
| 596 | 601 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 597 | - | return | |
| 602 | + | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | |
| 598 | 603 | } | |
| 599 | 604 | delete(d.stopped, target.Name) | |
| 600 | 605 | } | |
| 601 | 606 | d.mu.Unlock() | |
| 602 | - | return | |
| 607 | + | return nil | |
| 603 | 608 | } | |
| 604 | 609 | d.mu.Lock() | |
| 605 | 610 | targetSess := d.sessions[target.Session] | |
| @@ -607,6 +612,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 607 | 612 | if targetSess != nil { | |
| 608 | 613 | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 609 | 614 | } | |
| 615 | + | return nil | |
| 610 | 616 | } | |
| 611 | 617 | ||
| 612 | 618 | func (d *Daemon) refreshPicker(ac *attachedClient) { | |
| @@ -122,7 +122,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 122 | target, ok := ac.overlays.picker.Selected() | 122 | target, ok := ac.overlays.picker.Selected() | ||
| 123 | ac.overlays.pickerMu.Unlock() | 123 | ac.overlays.pickerMu.Unlock() | ||
| 124 | if ok { | 124 | if ok { | ||
| 125 | - | d.killPickerTarget(target) | 125 | + | if err := d.killPickerTarget(target); err != nil { |
| 126 | + | d.reportError(sess, err) | |||
| 127 | + | } | |||
| 126 | } | 128 | } | ||
| 127 | d.refreshPicker(ac) | 129 | d.refreshPicker(ac) | ||
| 128 | d.invalidateRender(sess, ac, true, "picker.go") | 130 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| @@ -171,7 +173,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 171 | d.closePicker(ac) | 173 | d.closePicker(ac) | ||
| 172 | } | 174 | } | ||
| 173 | if switchTarget && ok { | 175 | if switchTarget && ok { | ||
| 174 | - | d.switchToTarget(sess, ac, target) | 176 | + | if err := d.switchToTarget(sess, ac, target); err != nil { |
| 177 | + | d.reportError(sess, err) | |||
| 178 | + | } | |||
| 175 | return | 179 | return | ||
| 176 | } | 180 | } | ||
| 177 | if exit || changed { | 181 | if exit || changed { | ||
| @@ -394,19 +398,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 394 | // switchToTarget resolves named lifecycle targets and commits their transition | 398 | // switchToTarget resolves named lifecycle targets and commits their transition | ||
| 395 | // while d.mu is held. A named palette result is allowed to cross an | 399 | // while d.mu is held. A named palette result is allowed to cross an | ||
| 396 | // active/stopped transition, but it never follows a same-name replacement. | 400 | // active/stopped transition, but it never follows a same-name replacement. | ||
| 397 | - | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { | 401 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error { |
| 398 | d.mu.Lock() | 402 | d.mu.Lock() | ||
| 399 | var ( | 403 | var ( | ||
| 400 | targetSess *session | 404 | targetSess *session | ||
| 401 | old *attachedClient | 405 | old *attachedClient | ||
| 402 | cleanups []renderLifecycleCleanup | 406 | cleanups []renderLifecycleCleanup | ||
| 403 | switched bool | 407 | switched bool | ||
| 408 | + | cause error | |||
| 404 | ) | 409 | ) | ||
| 405 | if target.Name != "" { | 410 | if target.Name != "" { | ||
| 406 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | 411 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | ||
| 407 | if ok { | 412 | if ok { | ||
| 408 | if isStopped { | 413 | if isStopped { | ||
| 409 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | 414 | + | targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) |
| 410 | } else { | 415 | } else { | ||
| 411 | // The snapshot ID may describe the stopped representation. The | 416 | // The snapshot ID may describe the stopped representation. The | ||
| 412 | // locked name/lifecycle resolver chose this active incarnation. | 417 | // locked name/lifecycle resolver chose this active incarnation. | ||
| @@ -422,7 +427,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 422 | 427 | ||||
| 423 | if !switched { | 428 | if !switched { | ||
| 424 | d.invalidateRender(from, ac, true, "picker.go") | 429 | d.invalidateRender(from, ac, true, "picker.go") | ||
| 425 | - | return false | 430 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) |
| 426 | } | 431 | } | ||
| 427 | finishRenderLifecycleCleanups(cleanups) | 432 | finishRenderLifecycleCleanups(cleanups) | ||
| 428 | if old != nil && old != ac { | 433 | if old != nil && old != ac { | ||
| @@ -437,7 +442,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 437 | } else { | 442 | } else { | ||
| 438 | d.firstPaint(targetSess, ac, ac.size) | 443 | d.firstPaint(targetSess, ac, ac.size) | ||
| 439 | } | 444 | } | ||
| 440 | - | return true | 445 | + | return nil |
| 441 | } | 446 | } | ||
| 442 | 447 | ||||
| 443 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | 448 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | ||
| @@ -517,11 +522,11 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 517 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | 522 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | ||
| 518 | // the handoff while d.mu is held. Creation failure leaves the source client | 523 | // the handoff while d.mu is held. Creation failure leaves the source client | ||
| 519 | // and stopped record untouched. | 524 | // and stopped record untouched. | ||
| 520 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { | 525 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { |
| 521 | from.mu.Lock() | 526 | from.mu.Lock() | ||
| 522 | if from.client != ac { | 527 | if from.client != ac { | ||
| 523 | from.mu.Unlock() | 528 | from.mu.Unlock() | ||
| 524 | - | return nil, nil, false | 529 | + | return nil, nil, false, nil |
| 525 | } | 530 | } | ||
| 526 | term := from.terminal | 531 | term := from.terminal | ||
| 527 | env := copyEnvironment(from.env) | 532 | env := copyEnvironment(from.env) | ||
| @@ -530,7 +535,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 530 | if err != nil { | 535 | if err != nil { | ||
| 531 | from.mu.Unlock() | 536 | from.mu.Unlock() | ||
| 532 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 537 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 533 | - | return nil, nil, false | 538 | + | return nil, nil, false, err |
| 534 | } | 539 | } | ||
| 535 | from.client = nil | 540 | from.client = nil | ||
| 536 | ac.setSession(nil) | 541 | ac.setSession(nil) | ||
| @@ -543,7 +548,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 543 | targetSess.mu.Unlock() | 548 | targetSess.mu.Unlock() | ||
| 544 | d.touchMRU(targetSess) | 549 | d.touchMRU(targetSess) | ||
| 545 | ac.recordPreviousSession(from) | 550 | ac.recordPreviousSession(from) | ||
| 546 | - | return targetSess, cleanups, true | 551 | + | return targetSess, cleanups, true, nil |
| 547 | } | 552 | } | ||
| 548 | 553 | ||||
| 549 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | 554 | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | ||
| @@ -574,7 +579,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 574 | switched bool | 579 | switched bool | ||
| 575 | ) | 580 | ) | ||
| 576 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 581 | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | ||
| 577 | - | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | 582 | + | targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) |
| 578 | } | 583 | } | ||
| 579 | d.mu.Unlock() | 584 | d.mu.Unlock() | ||
| 580 | if !switched { | 585 | if !switched { | ||
| @@ -586,7 +591,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 586 | return true | 591 | return true | ||
| 587 | } | 592 | } | ||
| 588 | 593 | ||||
| 589 | - | func (d *Daemon) killPickerTarget(target picker.Target) { | 594 | + | func (d *Daemon) killPickerTarget(target picker.Target) error { |
| 590 | if target.Stopped { | 595 | if target.Stopped { | ||
| 591 | d.mu.Lock() | 596 | d.mu.Lock() | ||
| 592 | stopped, ok := d.stopped[target.Name] | 597 | stopped, ok := d.stopped[target.Name] | ||
| @@ -594,12 +599,12 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||||
| 594 | if err := d.persist.Delete(target.Name); err != nil { | 599 | if err := d.persist.Delete(target.Name); err != nil { | ||
| 595 | d.mu.Unlock() | 600 | d.mu.Unlock() | ||
| 596 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | 601 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | ||
| 597 | - | return | 602 | + | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) |
| 598 | } | 603 | } | ||
| 599 | delete(d.stopped, target.Name) | 604 | delete(d.stopped, target.Name) | ||
| 600 | } | 605 | } | ||
| 601 | d.mu.Unlock() | 606 | d.mu.Unlock() | ||
| 602 | - | return | 607 | + | return nil |
| 603 | } | 608 | } | ||
| 604 | d.mu.Lock() | 609 | d.mu.Lock() | ||
| 605 | targetSess := d.sessions[target.Session] | 610 | targetSess := d.sessions[target.Session] | ||
| @@ -607,6 +612,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||||
| 607 | if targetSess != nil { | 612 | if targetSess != nil { | ||
| 608 | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | 613 | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | ||
| 609 | } | 614 | } | ||
| 615 | + | return nil | |||
| 610 | } | 616 | } | ||
| 611 | 617 | ||||
| 612 | func (d *Daemon) refreshPicker(ac *attachedClient) { | 618 | func (d *Daemon) refreshPicker(ac *attachedClient) { | ||
M internal/usecase/daemon/picker_test.go +87 -2
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -22,6 +23,18 @@ import ( | |||
| 22 | 23 | ||
| 23 | 24 | // --- test doubles ----------------------------------------------------------- | |
| 24 | 25 | ||
| 26 | + | // failingDeleteStore is a ports.Store whose Delete always fails, used to | |
| 27 | + | // exercise the picker's kill-target error path without disturbing the | |
| 28 | + | // generated mock's default Delete expectation. | |
| 29 | + | type failingDeleteStore struct{ err error } | |
| 30 | + | ||
| 31 | + | func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false } | |
| 32 | + | func (failingDeleteStore) Set([]byte, []byte) error { return nil } | |
| 33 | + | func (s failingDeleteStore) Delete([]byte) error { return s.err } | |
| 34 | + | func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |
| 35 | + | func (failingDeleteStore) Sync() error { return nil } | |
| 36 | + | func (failingDeleteStore) Close() error { return nil } | |
| 37 | + | ||
| 25 | 38 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | |
| 26 | 39 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 27 | 40 | tb.ctx, tb.cancel = ctx, cancel | |
| @@ -140,7 +153,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 140 | 153 | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 141 | 154 | d.sessions[replacement.id] = replacement | |
| 142 | 155 | ||
| 143 | - | require.False(t, d.switchToTarget(current, ac, target)) | |
| 156 | + | require.Error(t, d.switchToTarget(current, ac, target)) | |
| 144 | 157 | require.Same(t, current, ac.currentSession()) | |
| 145 | 158 | } | |
| 146 | 159 | ||
| @@ -152,7 +165,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||
| 152 | 165 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 153 | 166 | current.client = &attachedClient{} | |
| 154 | 167 | ||
| 155 | - | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 168 | + | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 156 | 169 | require.Equal(t, 0, current.active) | |
| 157 | 170 | } | |
| 158 | 171 | ||
| @@ -945,3 +958,75 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 945 | 958 | t.Fatal("floating prewarm PTY was not closed") | |
| 946 | 959 | } | |
| 947 | 960 | } | |
| 961 | + | ||
| 962 | + | // TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives | |
| 963 | + | // Enter on a stopped picker entry whose restore spawn fails (mock PTY Open | |
| 964 | + | // error). The failure must reach the user as a NoticeSessionUnavailable | |
| 965 | + | // notice and toast, and the client must remain attached to its origin | |
| 966 | + | // session rather than being left in limbo. | |
| 967 | + | func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) { | |
| 968 | + | p, release := newBlockingPTY(t) | |
| 969 | + | defer release() | |
| 970 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |
| 971 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |
| 972 | + | cause := errors.New("open failed") | |
| 973 | + | ptys := portsmocks.NewMockPTYFactory(t) | |
| 974 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |
| 975 | + | d.ptys = ptys | |
| 976 | + | ||
| 977 | + | d.enterPicker(from, ac) | |
| 978 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 979 | + | d.handleInput(from, ac, []byte("j")) | |
| 980 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 981 | + | d.handleInput(from, ac, []byte("\r")) | |
| 982 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 983 | + | ||
| 984 | + | history := d.notices.history() | |
| 985 | + | require.NotEmpty(t, history, "failed stopped-session restore must record a notice") | |
| 986 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |
| 987 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |
| 988 | + | ||
| 989 | + | toasts := awaitToastCount(t, ac, 1) | |
| 990 | + | require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code) | |
| 991 | + | ||
| 992 | + | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |
| 993 | + | } | |
| 994 | + | ||
| 995 | + | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |
| 996 | + | // drives 'x' on a stopped picker entry whose persisted-record delete fails | |
| 997 | + | // (mock store error). The failure must reach the user as a | |
| 998 | + | // NoticePersistDelete notice and toast, and the entry must remain listed | |
| 999 | + | // after the picker refreshes rather than silently vanishing from the daemon's | |
| 1000 | + | // bookkeeping while still present on disk. | |
| 1001 | + | func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) { | |
| 1002 | + | p, release := newBlockingPTY(t) | |
| 1003 | + | defer release() | |
| 1004 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1005 | + | cause := errors.New("delete failed") | |
| 1006 | + | WithStore(failingDeleteStore{err: cause})(d) | |
| 1007 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |
| 1008 | + | ||
| 1009 | + | d.enterPicker(from, ac) | |
| 1010 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1011 | + | d.handleInput(from, ac, []byte("j")) | |
| 1012 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1013 | + | d.handleInput(from, ac, []byte("x")) | |
| 1014 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1015 | + | ||
| 1016 | + | history := d.notices.history() | |
| 1017 | + | require.NotEmpty(t, history, "failed persisted-record delete must record a notice") | |
| 1018 | + | require.Equal(t, domain.NoticePersistDelete, history[0].Code) | |
| 1019 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |
| 1020 | + | ||
| 1021 | + | toasts := awaitToastCount(t, ac, 1) | |
| 1022 | + | require.Equal(t, domain.NoticePersistDelete, toasts[0].Code) | |
| 1023 | + | ||
| 1024 | + | views, _ := d.pickerViews(from) | |
| 1025 | + | var stillListed bool | |
| 1026 | + | for _, v := range views { | |
| 1027 | + | if v.Stopped && v.Name == "stopped" { | |
| 1028 | + | stillListed = true | |
| 1029 | + | } | |
| 1030 | + | } | |
| 1031 | + | require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed") | |
| 1032 | + | } | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -22,6 +23,18 @@ import ( | |||||
| 22 | 23 | ||||
| 23 | // --- test doubles ----------------------------------------------------------- | 24 | // --- test doubles ----------------------------------------------------------- | ||
| 24 | 25 | ||||
| 26 | + | // failingDeleteStore is a ports.Store whose Delete always fails, used to | |||
| 27 | + | // exercise the picker's kill-target error path without disturbing the | |||
| 28 | + | // generated mock's default Delete expectation. | |||
| 29 | + | type failingDeleteStore struct{ err error } | |||
| 30 | + | ||||
| 31 | + | func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false } | |||
| 32 | + | func (failingDeleteStore) Set([]byte, []byte) error { return nil } | |||
| 33 | + | func (s failingDeleteStore) Delete([]byte) error { return s.err } | |||
| 34 | + | func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |||
| 35 | + | func (failingDeleteStore) Sync() error { return nil } | |||
| 36 | + | func (failingDeleteStore) Close() error { return nil } | |||
| 37 | + | ||||
| 25 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | 38 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | ||
| 26 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | 39 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | ||
| 27 | tb.ctx, tb.cancel = ctx, cancel | 40 | tb.ctx, tb.cancel = ctx, cancel | ||
| @@ -140,7 +153,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||||
| 140 | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | 153 | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | ||
| 141 | d.sessions[replacement.id] = replacement | 154 | d.sessions[replacement.id] = replacement | ||
| 142 | 155 | ||||
| 143 | - | require.False(t, d.switchToTarget(current, ac, target)) | 156 | + | require.Error(t, d.switchToTarget(current, ac, target)) |
| 144 | require.Same(t, current, ac.currentSession()) | 157 | require.Same(t, current, ac.currentSession()) | ||
| 145 | } | 158 | } | ||
| 146 | 159 | ||||
| @@ -152,7 +165,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||||
| 152 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | 165 | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | ||
| 153 | current.client = &attachedClient{} | 166 | current.client = &attachedClient{} | ||
| 154 | 167 | ||||
| 155 | - | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | 168 | + | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) |
| 156 | require.Equal(t, 0, current.active) | 169 | require.Equal(t, 0, current.active) | ||
| 157 | } | 170 | } | ||
| 158 | 171 | ||||
| @@ -945,3 +958,75 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 945 | t.Fatal("floating prewarm PTY was not closed") | 958 | t.Fatal("floating prewarm PTY was not closed") | ||
| 946 | } | 959 | } | ||
| 947 | } | 960 | } | ||
| 961 | + | ||||
| 962 | + | // TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives | |||
| 963 | + | // Enter on a stopped picker entry whose restore spawn fails (mock PTY Open | |||
| 964 | + | // error). The failure must reach the user as a NoticeSessionUnavailable | |||
| 965 | + | // notice and toast, and the client must remain attached to its origin | |||
| 966 | + | // session rather than being left in limbo. | |||
| 967 | + | func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) { | |||
| 968 | + | p, release := newBlockingPTY(t) | |||
| 969 | + | defer release() | |||
| 970 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 971 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |||
| 972 | + | cause := errors.New("open failed") | |||
| 973 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 974 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |||
| 975 | + | d.ptys = ptys | |||
| 976 | + | ||||
| 977 | + | d.enterPicker(from, ac) | |||
| 978 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 979 | + | d.handleInput(from, ac, []byte("j")) | |||
| 980 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 981 | + | d.handleInput(from, ac, []byte("\r")) | |||
| 982 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 983 | + | ||||
| 984 | + | history := d.notices.history() | |||
| 985 | + | require.NotEmpty(t, history, "failed stopped-session restore must record a notice") | |||
| 986 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 987 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |||
| 988 | + | ||||
| 989 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 990 | + | require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code) | |||
| 991 | + | ||||
| 992 | + | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |||
| 993 | + | } | |||
| 994 | + | ||||
| 995 | + | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 996 | + | // drives 'x' on a stopped picker entry whose persisted-record delete fails | |||
| 997 | + | // (mock store error). The failure must reach the user as a | |||
| 998 | + | // NoticePersistDelete notice and toast, and the entry must remain listed | |||
| 999 | + | // after the picker refreshes rather than silently vanishing from the daemon's | |||
| 1000 | + | // bookkeeping while still present on disk. | |||
| 1001 | + | func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) { | |||
| 1002 | + | p, release := newBlockingPTY(t) | |||
| 1003 | + | defer release() | |||
| 1004 | + | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1005 | + | cause := errors.New("delete failed") | |||
| 1006 | + | WithStore(failingDeleteStore{err: cause})(d) | |||
| 1007 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |||
| 1008 | + | ||||
| 1009 | + | d.enterPicker(from, ac) | |||
| 1010 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1011 | + | d.handleInput(from, ac, []byte("j")) | |||
| 1012 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1013 | + | d.handleInput(from, ac, []byte("x")) | |||
| 1014 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1015 | + | ||||
| 1016 | + | history := d.notices.history() | |||
| 1017 | + | require.NotEmpty(t, history, "failed persisted-record delete must record a notice") | |||
| 1018 | + | require.Equal(t, domain.NoticePersistDelete, history[0].Code) | |||
| 1019 | + | require.Equal(t, domain.NoticeError, history[0].Severity) | |||
| 1020 | + | ||||
| 1021 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 1022 | + | require.Equal(t, domain.NoticePersistDelete, toasts[0].Code) | |||
| 1023 | + | ||||
| 1024 | + | views, _ := d.pickerViews(from) | |||
| 1025 | + | var stillListed bool | |||
| 1026 | + | for _, v := range views { | |||
| 1027 | + | if v.Stopped && v.Name == "stopped" { | |||
| 1028 | + | stillListed = true | |||
| 1029 | + | } | |||
| 1030 | + | } | |||
| 1031 | + | require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed") | |||
| 1032 | + | } | |||
M internal/usecase/daemon/session_back.go +1 -1
| @@ -14,7 +14,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 14 | 14 | d.invalidateRender(current, ac, true, "session_back.go") | |
| 15 | 15 | return | |
| 16 | 16 | } | |
| 17 | - | d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 17 | + | _ = d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | // clearPreviousSessionIf clears target only if it has not been replaced since | |
| @@ -14,7 +14,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||||
| 14 | d.invalidateRender(current, ac, true, "session_back.go") | 14 | d.invalidateRender(current, ac, true, "session_back.go") | ||
| 15 | return | 15 | return | ||
| 16 | } | 16 | } | ||
| 17 | - | d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | 17 | + | _ = d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) |
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | // clearPreviousSessionIf clears target only if it has not been replaced since | 20 | // clearPreviousSessionIf clears target only if it has not been replaced since | ||
M internal/usecase/daemon/session_test.go +5 -5
| @@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin | |||
| 1079 | 1079 | require.NoError(t, d.renameSession(second, "named")) | |
| 1080 | 1080 | require.NotEqual(t, staleCreatedAt, second.createdAt) | |
| 1081 | 1081 | ||
| 1082 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | |
| 1082 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | |
| 1083 | 1083 | require.Same(t, from, ac.currentSession()) | |
| 1084 | 1084 | releaseSecond() | |
| 1085 | 1085 | d.sessWg.Wait() | |
| @@ -1729,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1729 | 1729 | target.createdAt = 22 | |
| 1730 | 1730 | expected := int64(21) | |
| 1731 | 1731 | ||
| 1732 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1732 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1733 | 1733 | require.Same(t, from, ac.currentSession()) | |
| 1734 | 1734 | }) | |
| 1735 | 1735 | ||
| @@ -1743,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1743 | 1743 | expected := int64(31) | |
| 1744 | 1744 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | |
| 1745 | 1745 | ||
| 1746 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1746 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1747 | 1747 | require.Equal(t, "target", ac.currentSession().name) | |
| 1748 | 1748 | require.Equal(t, expected, ac.currentSession().createdAt) | |
| 1749 | 1749 | }) | |
| @@ -1755,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1755 | 1755 | target.createdAt = 41 | |
| 1756 | 1756 | expected := int64(41) | |
| 1757 | 1757 | ||
| 1758 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1758 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1759 | 1759 | require.Same(t, target, ac.currentSession()) | |
| 1760 | 1760 | }) | |
| 1761 | 1761 | ||
| @@ -1766,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1766 | 1766 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | |
| 1767 | 1767 | expected := int64(51) | |
| 1768 | 1768 | ||
| 1769 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1769 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1770 | 1770 | require.Same(t, from, ac.currentSession()) | |
| 1771 | 1771 | }) | |
| 1772 | 1772 | } | |
| @@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin | |||||
| 1079 | require.NoError(t, d.renameSession(second, "named")) | 1079 | require.NoError(t, d.renameSession(second, "named")) | ||
| 1080 | require.NotEqual(t, staleCreatedAt, second.createdAt) | 1080 | require.NotEqual(t, staleCreatedAt, second.createdAt) | ||
| 1081 | 1081 | ||||
| 1082 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | 1082 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) |
| 1083 | require.Same(t, from, ac.currentSession()) | 1083 | require.Same(t, from, ac.currentSession()) | ||
| 1084 | releaseSecond() | 1084 | releaseSecond() | ||
| 1085 | d.sessWg.Wait() | 1085 | d.sessWg.Wait() | ||
| @@ -1729,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1729 | target.createdAt = 22 | 1729 | target.createdAt = 22 | ||
| 1730 | expected := int64(21) | 1730 | expected := int64(21) | ||
| 1731 | 1731 | ||||
| 1732 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | 1732 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) |
| 1733 | require.Same(t, from, ac.currentSession()) | 1733 | require.Same(t, from, ac.currentSession()) | ||
| 1734 | }) | 1734 | }) | ||
| 1735 | 1735 | ||||
| @@ -1743,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1743 | expected := int64(31) | 1743 | expected := int64(31) | ||
| 1744 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | 1744 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | ||
| 1745 | 1745 | ||||
| 1746 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | 1746 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) |
| 1747 | require.Equal(t, "target", ac.currentSession().name) | 1747 | require.Equal(t, "target", ac.currentSession().name) | ||
| 1748 | require.Equal(t, expected, ac.currentSession().createdAt) | 1748 | require.Equal(t, expected, ac.currentSession().createdAt) | ||
| 1749 | }) | 1749 | }) | ||
| @@ -1755,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1755 | target.createdAt = 41 | 1755 | target.createdAt = 41 | ||
| 1756 | expected := int64(41) | 1756 | expected := int64(41) | ||
| 1757 | 1757 | ||||
| 1758 | - | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | 1758 | + | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) |
| 1759 | require.Same(t, target, ac.currentSession()) | 1759 | require.Same(t, target, ac.currentSession()) | ||
| 1760 | }) | 1760 | }) | ||
| 1761 | 1761 | ||||
| @@ -1766,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1766 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | 1766 | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | ||
| 1767 | expected := int64(51) | 1767 | expected := int64(51) | ||
| 1768 | 1768 | ||||
| 1769 | - | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | 1769 | + | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) |
| 1770 | require.Same(t, from, ac.currentSession()) | 1770 | require.Same(t, from, ac.currentSession()) | ||
| 1771 | }) | 1771 | }) | ||
| 1772 | } | 1772 | } | ||