1package daemon
2
3import (
4 "context"
5
6 vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/ports"
10)
11
12type resizeOwnerPostEffect uint8
13
14const (
15 resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
16 resizeOwnerPostRetrySchedule
17 resizeOwnerPostRenderInvalidation
18 resizeOwnerPostCommitPublication
19)
20
21// resizeMember is an unpublished layout decision. Prepare only reads guarded
22// state; apply performs the external PTY call; commit is the sole publisher.
23type resizeMember struct {
24 session *session
25 tab *tab
26 pane *pane
27 owner paneEffectLease
28 rect domain.Rect
29 geometry domain.Geometry
30 floating floatingGeometry
31 isFloating bool
32 // floatingGeneration identifies the exact popup slot accepted by a failed
33 // resize. Floating panes are intentionally outside tb.panes, so retries
34 // must validate this generation and pointer rather than use tiled lookup.
35 floatingGeneration uint64
36 retry bool
37 ok bool
38 err error
39 screenResized bool
40}
41
42func resizePTYGeometry(pty ports.PTY, geometry domain.Geometry) error {
43 return pty.Resize(geometry.NormalizePixels())
44}
45
46func setScreenGeometry(screen *vt.Screen, geometry domain.Geometry) {
47 if screen == nil {
48 return
49 }
50 screen.SetGeometry(vt.Geometry{
51 Cols: geometry.Cols, Rows: geometry.Rows,
52 PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
53 })
54}
55
56type preparedTabLayout struct {
57 tab *tab
58 generation uint64
59 size domain.Size
60 previousSize domain.Size
61 members []resizeMember
62}
63
64func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
65 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
66}
67
68func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout {
69 plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size}
70 if tb.tree == nil || tb.tree.Root == nil || !size.Valid() {
71 return plan
72 }
73 area := domain.Rect{Width: size.Cols, Height: size.Rows}
74 placements, ok := layout.Solve(tb.tree.Root, area)
75 if !ok && tb.tree.Root.Kind == layout.Leaf {
76 placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
77 ok = true
78 }
79 if !ok {
80 return plan
81 }
82 for _, placement := range placements {
83 if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
84 continue
85 }
86 if p := tb.panes[placement.ID]; p != nil {
87 p.mu.Lock()
88 owner := p.effectLeaseLocked()
89 p.mu.Unlock()
90 paneSize := rectSize(placement.Content)
91 plan.members = append(plan.members, resizeMember{
92 session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content,
93 geometry: sess.geometry.paneGeometry(paneSize),
94 })
95 }
96 }
97 return plan
98}
99
100// applyPreparedTabMembers performs only the external PTY phase. A successful
101// resize leaves the parser gate closed until the complete plan is validated.
102func (g *sharedPTYGeometry) applyPreparedTabMembers(d *Daemon, plan *preparedTabLayout) {
103 for i := range plan.members {
104 member := &plan.members[i]
105 if !member.geometry.Valid() {
106 member.geometry = domain.Geometry{Size: rectSize(member.rect)}
107 }
108 p := member.pane
109 p.resizeMu.Lock()
110 p.mu.Lock()
111 if !resizeMemberOwnerCurrent(member) {
112 member.retry = p.owner.Load() != nil
113 p.resizeRetry = member.retry
114 p.mu.Unlock()
115 p.resizeMu.Unlock()
116 continue
117 }
118 old := p.rect
119 pty := p.pty
120 // A rejected earlier attempt deliberately leaves its parser gate open.
121 // Reapply even if the committed rectangle already matches: the PTY may
122 // still be at that rejected attempt's external size. An accepted failed
123 // attempt records resizeRetry for the same reason after its gate closes.
124 pixelGeometryChanged := (p.geometry.PixelsKnown() || member.geometry.PixelsKnown()) && p.geometry != member.geometry
125 needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height || pixelGeometryChanged
126 if needsApply && pty != nil {
127 p.resizeApplying = true
128 }
129 p.mu.Unlock()
130 if !needsApply || pty == nil {
131 member.ok = true
132 } else if err := resizePTYGeometry(pty, member.geometry); err != nil {
133 d.log.Warn("pty resize failed", "err", err)
134 p.mu.Lock()
135 current := resizeMemberOwnerCurrent(member)
136 p.resizeRetry = true
137 p.mu.Unlock()
138 d.replayResizePending(member.session, member.tab, p, false, member.rect)
139 member.retry = true
140 if current {
141 member.err = err
142 }
143 } else {
144 p.mu.Lock()
145 current := resizeMemberOwnerCurrent(member)
146 if !current && p.owner.Load() != nil {
147 p.resizeRetry = true
148 member.retry = true
149 }
150 p.mu.Unlock()
151 member.ok = current
152 member.screenResized = true // records that this member owns an open gate
153 }
154 p.resizeMu.Unlock()
155 }
156}
157
158func resizeMemberOwnerCurrent(member *resizeMember) bool {
159 if member == nil || member.pane == nil {
160 return false
161 }
162 if member.owner.owner == nil {
163 return true
164 }
165 owner := member.pane.owner.Load()
166 return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
167}
168
169func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
170 if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
171 return false
172 }
173 for i := range plan.members {
174 member := &plan.members[i]
175 if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
176 return false
177 }
178 }
179 return true
180}
181
182func resizeMembersOwnerCurrent(members []resizeMember) bool {
183 for i := range members {
184 if !resizeMemberOwnerCurrent(&members[i]) {
185 return false
186 }
187 }
188 return true
189}
190
191func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
192 for i := range plan.members {
193 member := &plan.members[i]
194 member.pane.mu.Lock()
195 current := resizeMemberOwnerCurrent(member)
196 if !current {
197 member.pane.resizeRetry = member.pane.owner.Load() != nil
198 member.retry = member.pane.resizeRetry
199 }
200 member.pane.mu.Unlock()
201 if !current {
202 return false
203 }
204 }
205 for i := range plan.members {
206 member := &plan.members[i]
207 member.pane.mu.Lock()
208 member.pane.rect = member.rect
209 if member.ok {
210 member.pane.resizeRetry = false
211 member.pane.geometry = member.geometry
212 setScreenGeometry(member.pane.screen, member.geometry)
213 }
214 member.pane.mu.Unlock()
215 }
216 return true
217}
218
219// cancelStalePreparedGates releases gates for members which disappeared from
220// the next solved layout (for example a stack member collapsed by a focus
221// change). Members that remain are deliberately left gated for the retry.
222func (g *sharedPTYGeometry) cancelStalePreparedGates(d *Daemon, sess *session, tb *tab, plan *preparedTabLayout) {
223 tb.mu.Lock()
224 latest := prepareTabLayoutLocked(sess, tb)
225 tb.mu.Unlock()
226 current := make(map[*pane]struct{}, len(latest.members))
227 for i := range latest.members {
228 current[latest.members[i].pane] = struct{}{}
229 }
230 for i := range plan.members {
231 member := &plan.members[i]
232 if !member.screenResized {
233 continue
234 }
235 if _, ok := current[member.pane]; ok {
236 continue
237 }
238 member.pane.resizeMu.Lock()
239 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
240 member.pane.resizeMu.Unlock()
241 }
242}
243
244func (g *sharedPTYGeometry) finishPreparedTabMembers(d *Daemon, plan *preparedTabLayout, accepted bool) {
245 if !accepted {
246 // cancelStalePreparedGates owns stale-plan cancellation. Keeping this
247 // path empty ensures a removed member's pending bytes are replayed once,
248 // after the fresh layout determines it cannot survive the retry.
249 return
250 }
251 for i := range plan.members {
252 member := &plan.members[i]
253 if !member.screenResized {
254 continue
255 }
256 member.pane.resizeMu.Lock()
257 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
258 member.pane.resizeMu.Unlock()
259 }
260}
261
262// applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
263// one per-tab single-writer loop, but never holds tab or pane state locks while
264// invoking PTY.Resize.
265func (g *sharedPTYGeometry) applyTabLayoutTransaction(d *Daemon, sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
266 return g.applyTabLayoutTransactionWithNotice(d, sess, tb, true, current...)
267}
268
269func (g *sharedPTYGeometry) applyTabLayoutTransactionWithNotice(d *Daemon, sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
270 if tb == nil {
271 return nil, false
272 }
273 tb.layoutApplyMu.Lock()
274 defer tb.layoutApplyMu.Unlock()
275 for {
276 if tb.ctx != nil && tb.ctx.Err() != nil {
277 return nil, false
278 }
279 if len(current) != 0 && !current[0]() {
280 return nil, false
281 }
282 tb.mu.Lock()
283 plan := prepareTabLayoutLocked(sess, tb)
284 tb.mu.Unlock()
285 g.applyPreparedTabMembers(d, &plan)
286
287 tb.mu.Lock()
288 accepted := validatePreparedTabLayoutLocked(tb, &plan)
289 if len(current) != 0 && !current[0]() {
290 accepted = false
291 }
292 if accepted {
293 accepted = commitPreparedTabLayoutLocked(&plan)
294 }
295 tb.mu.Unlock()
296 g.finishPreparedTabMembers(d, &plan, accepted)
297 if !accepted {
298 g.cancelStalePreparedGates(d, sess, tb, &plan)
299 if len(current) != 0 && !current[0]() {
300 for i := range plan.members {
301 member := &plan.members[i]
302 if !member.screenResized {
303 continue
304 }
305 member.pane.resizeMu.Lock()
306 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
307 member.pane.resizeMu.Unlock()
308 }
309 return nil, false
310 }
311 continue
312 }
313 failed := make([]resizeMember, 0)
314 for _, member := range plan.members {
315 if !member.ok {
316 failed = append(failed, member)
317 }
318 }
319 if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
320 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
321 "pane resize failed; retrying in background", failed[len(failed)-1].err)
322 }
323 return failed, true
324 }
325}
326
327func (g *sharedPTYGeometry) applyTabLayout(d *Daemon, sess *session, tb *tab) bool {
328 failed, ok := g.applyTabLayoutTransaction(d, sess, tb)
329 if !ok {
330 return false
331 }
332 markSnapshotDirty(sess)
333 if len(failed) != 0 {
334 g.scheduleAcceptedTabLayoutRetry(d, sess, tb)
335 }
336 return true
337}
338
339const maxAcceptedTabLayoutRetries = 3
340
341// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
342// worker is deduplicated, derives cancellation from the tab lifecycle, and
343// suppresses repeat degradation notices after the accepted initial failure.
344func (g *sharedPTYGeometry) scheduleAcceptedTabLayoutRetry(d *Daemon, sess *session, tb *tab) {
345 if d.clock == nil || tb == nil || tb.ctx == nil {
346 return
347 }
348 tb.layoutRetryMu.Lock()
349 if tb.layoutRetryRunning {
350 tb.layoutRetryMu.Unlock()
351 return
352 }
353 ctx, cancel := context.WithCancel(tb.ctx)
354 tb.layoutRetryRunning = true
355 tb.layoutRetryCancel = cancel
356 tb.layoutRetryMu.Unlock()
357
358 go func() {
359 defer func() {
360 cancel()
361 tb.layoutRetryMu.Lock()
362 tb.layoutRetryRunning = false
363 tb.layoutRetryCancel = nil
364 tb.layoutRetryMu.Unlock()
365 }()
366 for range maxAcceptedTabLayoutRetries {
367 timer := d.clock.NewTimer(minOutputRenderDeadline)
368 if timer == nil {
369 return
370 }
371 timerC := timer.C()
372 if timerC == nil {
373 timer.Stop()
374 return
375 }
376 select {
377 case <-ctx.Done():
378 timer.Stop()
379 return
380 case <-timerC:
381 }
382 if ctx.Err() != nil {
383 return
384 }
385 failed, ok := g.applyTabLayoutTransactionWithNotice(d, sess, tb, false, func() bool { return ctx.Err() == nil })
386 if !ok || len(failed) == 0 {
387 return
388 }
389 markSnapshotDirty(sess)
390 for _, ac := range sess.snapshotAttachments() {
391 d.invalidateRender(sess, ac, true, "transactional_resize.go")
392 }
393 }
394 }()
395}
396
397// replayResizePending completes a pane's apply epoch. It keeps the gate set
398// while each buffered batch goes through the normal VT path, so reads arriving
399// during replay join the same ordered stream. Failure deliberately omits the
400// Screen.Resize, parsing every byte at the old dimensions.
401func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
402 first := true
403 for {
404 p.mu.Lock()
405 if first && resized {
406 p.screen.Resize(size.Width, size.Height)
407 }
408 first = false
409 data := append([]byte(nil), p.resizePending...)
410 p.resizePending = p.resizePending[:0]
411 if len(data) == 0 {
412 p.resizeApplying = false
413 p.mu.Unlock()
414 return
415 }
416 p.mu.Unlock()
417 if sess == nil {
418 p.mu.Lock()
419 writePaneScreenLocked(p, data)
420 p.refreshTerminalTitleLocked()
421 p.mu.Unlock()
422 continue
423 }
424 d.processPTYData(sess, tb, p, data, false)
425 }
426}
427
428// applyVisibleFloatingLayout retains floating panes' existing independent
429// lifecycle while routing their external resize through the same lock-free gate
430// used for retryable PTY work. Floating state is not part of a tiled layout
431// generation, so its slot generation and exact pane identity validate its
432// publication instead.
433func (g *sharedPTYGeometry) applyVisibleFloatingLayout(d *Daemon, sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
434 return g.applyVisibleFloatingLayoutForMember(d, sess, tb, current, nil)
435}
436
437// applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
438// exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
439// so accepting a replacement here would silently retry unrelated geometry.
440func (g *sharedPTYGeometry) applyVisibleFloatingLayoutForMember(d *Daemon, sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
441 if tb == nil {
442 return nil, true
443 }
444 tb.mu.Lock()
445 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
446 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
447 tb.mu.Unlock()
448 return nil, true
449 }
450 p := tb.floating.pane
451 generation := tb.floating.generation
452 size := tb.size
453 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
454 tb.mu.Unlock()
455 if !geometry.committable() {
456 return nil, true
457 }
458 if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
459 // A drawer can validly reserve only its separator row. Publish that
460 // presentation without inventing a physical PTY size; the last usable
461 // rectangle and screen remain committed until a later resize has content.
462 tb.mu.Lock()
463 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
464 tb.floating.pane == p && tb.size == size
465 if current != nil && !current() {
466 currentSlot = false
467 }
468 if currentSlot {
469 p.mu.Lock()
470 p.popupGeometry = geometry
471 p.mu.Unlock()
472 tb.mu.Unlock()
473 return nil, true
474 }
475 tb.mu.Unlock()
476 if current != nil && !current() {
477 return nil, false
478 }
479 return nil, true
480 }
481
482 // This keeps successful PTY resizes gated until the
483 // floating slot and tab size are revalidated. A newer client resize may
484 // otherwise publish this obsolete popup geometry after its PTY call returns.
485 p.mu.Lock()
486 owner := p.effectLeaseLocked()
487 p.mu.Unlock()
488 ptyRect := geometry.ptyRect()
489 plan := preparedTabLayout{members: []resizeMember{{
490 session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect,
491 geometry: sess.geometry.paneGeometry(rectSize(ptyRect)), floating: geometry,
492 isFloating: true, floatingGeneration: generation,
493 }}}
494 g.applyPreparedTabMembers(d, &plan)
495
496 // The PTY may have accepted an intermediate size, but a hidden, replaced,
497 // relaunched, or resized slot must never receive this attempt's geometry.
498 tb.mu.Lock()
499 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
500 if current != nil && !current() {
501 currentSlot = false
502 }
503 if currentSlot && plan.members[0].ok {
504 p.mu.Lock()
505 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
506 if currentSlot {
507 p.rect = plan.members[0].rect
508 p.geometry = plan.members[0].geometry
509 p.popupGeometry = geometry
510 p.resizeRetry = false
511 setScreenGeometry(p.screen, plan.members[0].geometry)
512 }
513 p.mu.Unlock()
514 }
515 tb.mu.Unlock()
516 if !currentSlot {
517 // This attempt has no tiled transaction loop to retain the gate for, so
518 // discard its buffered bytes at the old screen size before returning.
519 for i := range plan.members {
520 member := &plan.members[i]
521 if !member.screenResized {
522 continue
523 }
524 member.pane.resizeMu.Lock()
525 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
526 member.pane.resizeMu.Unlock()
527 }
528 if current != nil && !current() {
529 return nil, false
530 }
531 return nil, true
532 }
533 if plan.members[0].ok {
534 g.finishPreparedTabMembers(d, &plan, true)
535 return nil, true
536 }
537 return plan.members, true
538}
539
540// releasePreparedSessionGates abandons a session attempt which cannot be
541// retried here (for example because a newer coordinator epoch won). Unlike a
542// stale per-tab retry, no succeeding attempt is owned by this call, so every
543// successful external resize must reopen its parser gate at the old screen
544// size.
545func (g *sharedPTYGeometry) releasePreparedSessionGates(d *Daemon, plans []*preparedTabLayout) {
546 for _, plan := range plans {
547 if plan == nil {
548 continue
549 }
550 for i := range plan.members {
551 member := &plan.members[i]
552 if !member.screenResized {
553 continue
554 }
555 member.pane.resizeMu.Lock()
556 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
557 member.pane.resizeMu.Unlock()
558 }
559 }
560}
561
562// applySessionLayout keeps client resize publication as a two-phase session
563// transaction: all PTYs are applied and plans validated first, then the
564// coordinator admits the epoch before any tab size, rectangle, VT screen,
565// snapshot dirtiness, or resize telemetry becomes visible.
566func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
567 if sess == nil {
568 return nil, false
569 }
570 sess.layoutApplyMu.Lock()
571 defer sess.layoutApplyMu.Unlock()
572
573 target := contentSize(size)
574 for {
575 if sess.ctx != nil && sess.ctx.Err() != nil {
576 return nil, false
577 }
578 if current != nil && !current() {
579 return nil, false
580 }
581 sess.mu.Lock()
582 tabs := append([]*tab(nil), sess.tabs...)
583 sess.mu.Unlock()
584 plans := make([]*preparedTabLayout, 0, len(tabs))
585 for _, tb := range tabs {
586 tb.mu.Lock()
587 plan := prepareTabLayoutForSizeLocked(sess, tb, target)
588 tb.mu.Unlock()
589 plans = append(plans, &plan)
590 }
591 for _, plan := range plans {
592 g.applyPreparedTabMembers(d, plan)
593 }
594
595 // Hold every tab lock across final validation, epoch admission, and all
596 // publication. This makes the session commit indivisible to layout
597 // mutators while still keeping PTY.Resize outside every state lock.
598 for _, tb := range tabs {
599 tb.mu.Lock()
600 }
601 valid := true
602 for _, plan := range plans {
603 if !validatePreparedTabLayoutLocked(plan.tab, plan) {
604 valid = false
605 break
606 }
607 }
608 // This is the final external-apply validation boundary. Tests use the
609 // seam to install a newer epoch here; that epoch must reject admission
610 // before this attempt publishes any session geometry.
611 if valid && d.beforeSessionResizePublication != nil {
612 d.beforeSessionResizePublication()
613 }
614 // Claim publication and shared geometry commit use one fence. The hook
615 // above remains outside the fence so tests and lifecycle callbacks can
616 // publish a newer claim; the final validation then rejects this stale plan.
617 g.mu.Lock()
618 if valid && sess.ctx != nil && sess.ctx.Err() != nil {
619 valid = false
620 }
621 if valid && current != nil && !current() {
622 valid = false
623 }
624 if valid && admit != nil && !admit() {
625 valid = false
626 }
627 if valid {
628 for _, plan := range plans {
629 if !commitPreparedTabLayoutLocked(plan) {
630 valid = false
631 break
632 }
633 plan.tab.size = plan.size
634 }
635 }
636 g.mu.Unlock()
637 for i := len(tabs) - 1; i >= 0; i-- {
638 tabs[i].mu.Unlock()
639 }
640 if !valid {
641 g.releasePreparedSessionGates(d, plans)
642 // Headless requests have no attachment epoch. A live tab mutation
643 // still invalidates their plan, but must trigger a fresh prepare rather
644 // than being mistaken for a canceled request.
645 if current != nil && !current() {
646 return nil, false
647 }
648 // A layout mutation invalidated the plans while this epoch remains
649 // current. Reapply the fresh session geometry before admitting it.
650 continue
651 }
652
653 failed := make([]resizeMember, 0)
654 for _, plan := range plans {
655 g.finishPreparedTabMembers(d, plan, true)
656 for _, member := range plan.members {
657 if !member.ok {
658 failed = append(failed, member)
659 }
660 }
661 }
662 for _, tb := range tabs {
663 floatingFailed, ok := g.applyVisibleFloatingLayout(d, sess, tb, current)
664 if !ok {
665 return nil, false
666 }
667 failed = append(failed, floatingFailed...)
668 }
669 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
670 if current != nil && !current() {
671 return nil, false
672 }
673 if len(failed) != 0 && failedCurrent {
674 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
675 "pane resize failed; retrying in background", failed[len(failed)-1].err)
676 }
677 if len(failed) != 0 && !failedCurrent {
678 return nil, false
679 }
680 if current != nil {
681 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
682 if !current() {
683 return nil, false
684 }
685 }
686 markSnapshotDirty(sess)
687 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
688 return failed, true
689 }
690}