diff --git a/Makefile b/Makefile index ff197ee..f52de20 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION ?= dev +VERSION ?= v1.5.4 LDFLAGS := -trimpath -ldflags "-s -w -X main.version=$(VERSION)" .PHONY: build build-server bundle dmg install clean diff --git a/config/config.go b/config/config.go index c66011d..fea64ca 100644 --- a/config/config.go +++ b/config/config.go @@ -18,7 +18,7 @@ const configTemplate = `# zurm configuration [font] family = "JetBrains Mono" -size = 15 +size = 15 # points, clamped to 6–72 # file = "/Users/you/Library/Fonts/JetBrainsMonoNerdFont-Regular.ttf" # overrides embedded font # # Font fallback chain — tried in order for missing glyphs. @@ -45,9 +45,9 @@ size = 15 # -o ~/Library/Fonts/NotoSansSymbols2-Regular.ttf [window] -columns = 120 -rows = 35 -padding = 4 # pixels inside each pane edge +columns = 120 # min 1 +rows = 35 # min 1 +padding = 4 # pixels inside each pane edge (min 0) [shell] program = "" # empty = read from $SHELL, fallback /bin/zsh @@ -191,6 +191,15 @@ md_badge_bg = "#FFCC00" # badge/label background (e.g. line count) md_badge_fg = "#000000" # badge/label text color ` +// Renderable font-size bounds, in points. A size at or below zero collapses the +// glyph cell to 0×0, which divides by zero throughout the layout math; an absurd +// size wastes memory. These are the single source of truth for the valid range — +// enforced on every config load and by the interactive font resize. +const ( + MinFontSize = 6.0 + MaxFontSize = 72.0 +) + type FontConfig struct { Family string `toml:"family"` Size float64 `toml:"size"` @@ -505,6 +514,7 @@ func Load() (*Config, error) { } meta, err := toml.DecodeFile(path, &cfg) + normalize(&cfg) if err != nil { return &cfg, err } @@ -527,6 +537,7 @@ func LoadWithMeta() (*Config, toml.MetaData, error) { cfg := Defaults meta, err := toml.DecodeFile(path, &cfg) + normalize(&cfg) if err != nil { return &cfg, meta, fmt.Errorf("config: %w", err) } @@ -534,6 +545,45 @@ func LoadWithMeta() (*Config, toml.MetaData, error) { return &cfg, meta, nil } +// normalize enforces config invariants after decode so every consumer can trust +// the values without re-checking them. Applied on every load (startup and +// hot-reload). Each clamp logs so a user with a bad config gets a hint. +func normalize(cfg *Config) { + clampFontSize(cfg) + clampWindow(cfg) +} + +// clampWindow bounds window geometry. Columns/Rows seed the initial buffer +// allocation (NewScreenBuffer) — a value below 1 panics there; padding below 0 +// is meaningless. (NewScreenBuffer floors dims too, as defense in depth.) +func clampWindow(cfg *Config) { + if cfg.Window.Columns < 1 { + log.Printf("config: window.columns %d invalid, clamping to 1", cfg.Window.Columns) + cfg.Window.Columns = 1 + } + if cfg.Window.Rows < 1 { + log.Printf("config: window.rows %d invalid, clamping to 1", cfg.Window.Rows) + cfg.Window.Rows = 1 + } + if cfg.Window.Padding < 0 { + log.Printf("config: window.padding %d invalid, clamping to 0", cfg.Window.Padding) + cfg.Window.Padding = 0 + } +} + +// clampFontSize bounds Font.Size to the renderable range. Applied on every load +// so all consumers (startup, hot-reload, interactive resize) can trust the value +// without re-checking it. A clamp is logged so a user with a bad config gets a hint. +func clampFontSize(cfg *Config) { + if cfg.Font.Size < MinFontSize { + log.Printf("config: font.size %.1f below minimum, clamping to %.0f", cfg.Font.Size, MinFontSize) + cfg.Font.Size = MinFontSize + } else if cfg.Font.Size > MaxFontSize { + log.Printf("config: font.size %.1f above maximum, clamping to %.0f", cfg.Font.Size, MaxFontSize) + cfg.Font.Size = MaxFontSize + } +} + // resolveShell fills in Shell.Program from $SHELL if not set. func resolveShell(cfg *Config) { if cfg.Shell.Program == "" { diff --git a/config/fontsize_clamp_test.go b/config/fontsize_clamp_test.go new file mode 100644 index 0000000..65594e3 --- /dev/null +++ b/config/fontsize_clamp_test.go @@ -0,0 +1,53 @@ +package config + +import "testing" + +func TestClampFontSize(t *testing.T) { + cases := []struct { + name string + in float64 + want float64 + }{ + {"zero collapses to min", 0, MinFontSize}, + {"negative collapses to min", -10, MinFontSize}, + {"below min", MinFontSize - 0.1, MinFontSize}, + {"at min stays", MinFontSize, MinFontSize}, + {"in range stays", 15, 15}, + {"at max stays", MaxFontSize, MaxFontSize}, + {"above max collapses to max", MaxFontSize + 100, MaxFontSize}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := Config{Font: FontConfig{Size: c.in}} + clampFontSize(&cfg) + if cfg.Font.Size != c.want { + t.Errorf("clampFontSize(%v) = %v, want %v", c.in, cfg.Font.Size, c.want) + } + }) + } +} + +func TestClampWindow(t *testing.T) { + cases := []struct { + name string + cols, rows, pad int + wantCols, wantRows, wantPadding int + }{ + {"negative cols/rows", -1, -1, 4, 1, 1, 4}, + {"zero cols/rows", 0, 0, 4, 1, 1, 4}, + {"negative padding", 80, 24, -5, 80, 24, 0}, + {"valid values untouched", 120, 35, 4, 120, 35, 4}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := Config{Window: WindowConfig{Columns: c.cols, Rows: c.rows, Padding: c.pad}} + clampWindow(&cfg) + if cfg.Window.Columns != c.wantCols || cfg.Window.Rows != c.wantRows || cfg.Window.Padding != c.wantPadding { + t.Errorf("clampWindow(cols=%d,rows=%d,pad=%d) = (%d,%d,%d), want (%d,%d,%d)", + c.cols, c.rows, c.pad, + cfg.Window.Columns, cfg.Window.Rows, cfg.Window.Padding, + c.wantCols, c.wantRows, c.wantPadding) + } + }) + } +} diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md new file mode 100644 index 0000000..8272e1d --- /dev/null +++ b/docs/__changelog__/releases/1.5.4.md @@ -0,0 +1,24 @@ +--- +version: "1.5.4" +date: "2026-05-17T00:00:00Z" +--- + +## Fixed + +- **Hang on wake from display sleep** — zurm could freeze permanently (requiring a force-quit) after a display slept and woke: closing the laptop lid in clamshell mode, a monitor power-cycling, or the display idle-timeout. When idle, zurm skipped the GPU present to save CPU; on macOS 14+ that stranded Ebitengine's display-link callback thread, which then deadlocked the main thread inside QuartzCore the next time a display was reconfigured. zurm now re-presents the last frame every tick so the present loop never stalls. + +- **Tick rate not restored after sleep** — after a sleep/wake cycle the engine could keep running at 60 TPS instead of the configured `[performance] tps` value, raising idle CPU. The configured rate is now always restored on wake. + +- **Crash on display sleep or disconnect** — zurm could panic (`ebiten: width at NewImage must be positive but 0`) when a display went to sleep or an external monitor was unplugged: with no screen attached the window reports a `0×0` size, which zurm then tried to allocate render buffers for. Resizes to a zero-sized window are now ignored; layout recommits automatically once a display is back. + +- **Scroll jumped on focus change** — with smooth scrolling enabled, clicking a pane to focus it (or switching tabs) could snap its viewport up or down at random. The scroll animation tracked the previous pane's position and forced it onto the newly focused pane. Each pane now keeps its own scroll position across focus and tab changes. + +- **Window not resized when moved to another display** — dragging zurm onto a different monitor could leave the content stranded at its old size in the top-left corner with the rest of the window black. The render surface was sized from `ebiten.WindowSize()`, which lags the framebuffer that actually drives rendering when the window crosses displays. Render geometry is now committed from the size ebiten renders against, so content fills the window after a move. + +- **Crash on invalid font size** — a `font.size` of zero or below in config (including via a typo during a live config edit, which hot-reloads) collapsed the glyph cell to 0×0 and panicked with an integer divide-by-zero. Font size is now clamped to a renderable range (6–72 points) on every load, and the renderer guards against a zero-sized cell as a final safeguard. + +- **Crash on invalid window dimensions** — a `window.columns` or `window.rows` of zero or below panicked at startup when allocating the terminal buffer. Window columns/rows are now clamped to a minimum of 1 and padding to a minimum of 0 on load, and the screen buffer floors its own dimensions as a final safeguard. + +- **Click-count carried across panes** — clicking to select in one pane then quickly clicking the same cell in another pane could register as a double/triple click. Click tracking now resets when focus changes panes. + +- **Crash restoring a corrupt session** — a session file with an out-of-range active tab index (e.g. negative, from manual editing or corruption) panicked at startup, preventing launch until the file was deleted. The restored active-tab index is now clamped to a valid range. diff --git a/docs/getting-started/02-configuration.md b/docs/getting-started/02-configuration.md index 7640c02..43d27a1 100644 --- a/docs/getting-started/02-configuration.md +++ b/docs/getting-started/02-configuration.md @@ -14,14 +14,14 @@ Hot-reload with `Cmd+,` — no restart needed. ```toml [font] family = "JetBrains Mono" # font family name (informational) -size = 15 # font size in points +size = 15 # font size in points (clamped to 6–72) # file = "/path/to/Font.ttf" # custom TTF/OTF; overrides embedded JetBrains Mono # fallbacks = [...] # see Optional Fonts page [window] -columns = 120 # initial terminal width in character columns -rows = 35 # initial terminal height in character rows -padding = 4 # pixels inside each pane edge +columns = 120 # initial terminal width in character columns (min 1) +rows = 35 # initial terminal height in character rows (min 1) +padding = 4 # pixels inside each pane edge (min 0) [shell] program = "" # shell binary; empty = read from $SHELL, fallback /bin/zsh diff --git a/game_drain.go b/game_drain.go index 8d2793e..fb3b606 100644 --- a/game_drain.go +++ b/game_drain.go @@ -19,13 +19,29 @@ import ( // No-op when cfg.Scroll.Smooth is false. Called every Update() after // handleInput so the target is already updated for this frame. func (g *Game) tickSmoothScroll() { - if !g.cfg.Scroll.Smooth || g.activeFocused() == nil { + if !g.cfg.Scroll.Smooth { return } - buf := g.activeFocused().Term.Buf + p := g.activeFocused() + if p == nil { + return + } + buf := p.Term.Buf buf.Lock() defer buf.Unlock() + // Focus or tab changed since the last tick. The animation state is global + // to the gesture, but each pane owns its own ViewOffset — so re-sync to the + // newly focused pane instead of forcing the previous pane's scroll position + // onto it. Without this, clicking/switching to another pane snaps it to + // wherever the last pane was scrolled (random up/down jump). + if p != g.smoothScrollPane { + g.smoothScrollPane = p + g.smoothScrollPos = float64(buf.ViewOffset) + g.smoothScrollTarget = g.smoothScrollPos + return + } + maxTarget := float64(buf.ScrollbackLen()) if g.smoothScrollTarget < 0 { g.smoothScrollTarget = 0 @@ -58,7 +74,7 @@ func (g *Game) handleResize() { // switches, and multi-monitor DPI transitions — all of which take variable // time that a fixed frame count cannot safely bound. if g.screenSettleFrames > 0 { - w, h := ebiten.WindowSize() + w, h := g.logicalSize() dpi := g.monitorDPI() if w != g.screenSettleW || h != g.screenSettleH || dpi != g.screenSettleDPI { // Geometry still changing — restart the wait. @@ -75,7 +91,16 @@ func (g *Game) handleResize() { return } - w, h := ebiten.WindowSize() + w, h := g.logicalSize() + // A sleeping or disconnected display reports a 0-sized window (glfw logs + // "Cannot query content scale without screen"). Committing 0×0 would panic + // in ebiten.NewImage via Renderer.SetSize. Guard on the live window size + // (the authoritative "screen attached" signal — it can't go stale) as well + // as the Layout-derived commit dims, which can briefly lag a disconnect. + // Geometry recommits naturally once a real display is back. + if lw, lh := ebiten.WindowSize(); lw <= 0 || lh <= 0 || w <= 0 || h <= 0 { + return + } dpi := g.monitorDPI() dpiChanged := dpi != g.dpi if w == g.winW && h == g.winH && !dpiChanged { @@ -448,7 +473,7 @@ func (g *Game) drainShellIntegration() { } case 'C': // Command about to execute — one-shot query for foreground name. - go p.Term.QueryForeground(g.ctx) + go p.Term.QueryForeground() } default: } @@ -462,14 +487,14 @@ func (g *Game) pollStatusOnOutput() { if g.status.Poller.ShouldPollCwd(seq) { if g.activeFocused() != nil { - go g.activeFocused().Term.QueryCWD(g.ctx) + go g.activeFocused().Term.QueryCWD() } } if g.cfg.StatusBar.ShowProcess && g.status.Poller.ShouldPollFg(seq) && g.tabMgr.ActiveIdx < len(g.tabMgr.Tabs) { for _, leaf := range g.tabMgr.Tabs[g.tabMgr.ActiveIdx].Layout.Leaves() { if !leaf.Pane.Term.HasOSC133() { - go leaf.Pane.Term.QueryForeground(g.ctx) + go leaf.Pane.Term.QueryForeground() } } } diff --git a/game_lifecycle.go b/game_lifecycle.go index 96d83a3..b64fe61 100644 --- a/game_lifecycle.go +++ b/game_lifecycle.go @@ -36,7 +36,7 @@ func (g *Game) handleFocus() { // wait. handleResize will keep sampling window size/DPI each frame and // only commit layout once they have been unchanged for 3 consecutive // frames, so zurm adapts to however long EDID negotiation actually takes. - w, h := ebiten.WindowSize() + w, h := g.logicalSize() dpi := g.monitorDPI() g.winW = w g.screenSettleW = w @@ -173,6 +173,18 @@ func (g *Game) monitorDPI() float64 { return g.dpi } +// logicalSize returns the window's logical size as ebiten last reported it to +// Layout. ebiten.WindowSize() can lag the framebuffer that drives Layout when +// the window moves between displays, so the Layout-reported size is the +// authoritative source for committing render geometry. Falls back to +// ebiten.WindowSize() before the first Layout call. +func (g *Game) logicalSize() (int, int) { + if g.layoutW > 0 && g.layoutH > 0 { + return g.layoutW, g.layoutH + } + return ebiten.WindowSize() +} + // physSize returns the physical pixel dimensions of the window. func (g *Game) physSize() (int, int) { return int(float64(g.winW) * g.dpi), int(float64(g.winH) * g.dpi) diff --git a/game_misc.go b/game_misc.go index c752b5d..3d17ec0 100644 --- a/game_misc.go +++ b/game_misc.go @@ -756,8 +756,8 @@ func (g *Game) forceRefresh() { g.flashStatus("Refreshed") return } - go g.activeFocused().Term.QueryCWD(g.ctx) - g.activeFocused().Term.RefreshForeground(g.ctx) + go g.activeFocused().Term.QueryCWD() + g.activeFocused().Term.RefreshForeground() if g.cfg.StatusBar.ShowGit && g.status.Bar.Cwd != "" { g.status.Poller.StartGitQuery(g.status.Bar.Cwd) } @@ -811,7 +811,7 @@ func (g *Game) reloadRuntimeSettings(cfg *config.Config) { if cfg.Keyboard.RepeatIntervalMs > 0 { keyRepeatInterval = time.Duration(cfg.Keyboard.RepeatIntervalMs) * time.Millisecond } - ebiten.SetTPS(cfg.Performance.TPS) + applyTPS(cfg.Performance.TPS) g.blocksEnabled = cfg.Blocks.Enabled g.renderer.BlocksEnabled = g.blocksEnabled @@ -874,24 +874,20 @@ func (g *Game) switchTheme(name string) { g.flashStatus("Theme: " + name) } -const ( - minFontSizePt = 6 // smallest allowed font size in points - maxFontSizePt = 72 // largest allowed font size in points -) - // adjustFontSize changes the font size by delta points and reloads the font. -// Clamped to [minFontSizePt, maxFontSizePt]. Skipped when recording is active. +// Clamped to the renderable range (config.MinFontSize..MaxFontSize). Skipped +// when recording is active. func (g *Game) adjustFontSize(delta float64) { if g.rec.Recorder != nil && g.rec.Recorder.Active() { g.flashStatus("Cannot resize font while recording") return } newSize := g.cfg.Font.Size + delta - if newSize < minFontSizePt { - newSize = minFontSizePt + if newSize < config.MinFontSize { + newSize = config.MinFontSize } - if newSize > maxFontSizePt { - newSize = maxFontSizePt + if newSize > config.MaxFontSize { + newSize = config.MaxFontSize } if newSize == g.cfg.Font.Size { return diff --git a/game_panes.go b/game_panes.go index 2d6c784..48b51bb 100644 --- a/game_panes.go +++ b/game_panes.go @@ -225,8 +225,14 @@ func (g *Game) setFocusNoHistory(p *pane.Pane) { g.input.MouseHeldBtn = -1 g.input.LastMouseCol = 0 g.input.LastMouseRow = 0 + // Reset click-count tracking so a click in the previous pane cannot combine + // with the first click in the newly focused pane into a false double/triple + // click. The sentinel cell guarantees the next click is never "same cell". + g.input.LastClickRow = -1 + g.input.LastClickCol = -1 + g.input.ClickCount = 0 g.status.Bar.ForegroundProc = "" - p.Term.RefreshForeground(g.ctx) + p.Term.RefreshForeground() if g.search.State.Open { g.closeSearchOverlay() } diff --git a/game_tabs.go b/game_tabs.go index 778c9f0..71e5fbf 100644 --- a/game_tabs.go +++ b/game_tabs.go @@ -303,7 +303,7 @@ func (g *Game) switchTabNoHistory(i int) { g.input.SelDrag.Active = false g.status.Bar.ForegroundProc = "" if f := g.activeFocused(); f != nil { - f.Term.RefreshForeground(g.ctx) + f.Term.RefreshForeground() } if g.search.State.Open { g.closeSearchOverlay() diff --git a/main.go b/main.go index ab7d697..f0e0cda 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime/debug" "strings" + "sync/atomic" "time" "github.com/hajimehoshi/ebiten/v2" @@ -33,6 +34,34 @@ import ( // Defaults to "dev" for local builds. var version = "dev" +// configuredTPS records the user-configured Ebiten tick rate so the +// sleepWatcher goroutine can restore it after a sleep/wake cycle. Stored +// atomically because sleepWatcher runs off the game loop. +var configuredTPS atomic.Int32 + +// applyTPS sets the Ebiten tick rate and records the configured value for +// sleepWatcher to restore on wake. Use this for the user-configured rate +// only — transient rates (idle TPS=5, sleep TPS=0) call ebiten.SetTPS directly. +func applyTPS(tps int) { + if tps <= 0 { + tps = config.Defaults.Performance.TPS + } + configuredTPS.Store(int32(tps)) // #nosec G115 — tps is a small positive TPS value + ebiten.SetTPS(tps) +} + +// restoreConfiguredTPS resets the Ebiten tick rate to the value recorded by +// applyTPS. Called from the sleepWatcher goroutine to unfreeze the loop after +// a sleep/wake cycle. Lives here (not in the cgo file wake_darwin.go) so the +// int32→int widening stays out of cgo's line-remapped source. +func restoreConfiguredTPS() { + tps := int(configuredTPS.Load()) + if tps <= 0 { + tps = config.Defaults.Performance.TPS + } + ebiten.SetTPS(tps) +} + // Internal timing constants — not user-configurable. const ( unfocusSuspendDelay = 5 * time.Second // idle before reducing TPS when unfocused @@ -116,6 +145,11 @@ type Game struct { cfg *config.Config winW, winH int dpi float64 // device pixel ratio (2.0 on Retina) + // layoutW/H is the logical window size ebiten last passed to Layout. + // ebiten.WindowSize() can lag the framebuffer that drives Layout when the + // window moves between displays, so this is the authoritative logical size + // for committing render geometry. + layoutW, layoutH int zoomed bool // focused pane temporarily fullscreened (Cmd+Z) // Grouped state — each sub-struct owns a clear concern. @@ -144,8 +178,9 @@ type Game struct { // smoothScroll* tracks the ease-out animation for wheel scroll. // Only active when cfg.Scroll.Smooth is true. - smoothScrollPos float64 // current animated ViewOffset (fractional) - smoothScrollTarget float64 // destination ViewOffset + smoothScrollPos float64 // current animated ViewOffset (fractional) + smoothScrollTarget float64 // destination ViewOffset + smoothScrollPane *pane.Pane // pane the animation last drove; resync on change // screenSettle* tracks display-change stabilisation. // When NSApplicationDidChangeScreenParametersNotification fires, zurm must @@ -358,7 +393,12 @@ func main() { } if len(initialTabs) > 0 { initialActive = sess.ActiveTab - if initialActive >= len(initialTabs) { + // Clamp to a valid index — a corrupt or hand-edited session file can + // carry a negative or out-of-range active_tab, which would panic when + // initialTabs[initialActive] is dereferenced below. + if initialActive < 0 { + initialActive = 0 + } else if initialActive >= len(initialTabs) { initialActive = len(initialTabs) - 1 } } @@ -510,7 +550,7 @@ func main() { ebiten.SetWindowTitle("zurm") ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetScreenClearedEveryFrame(false) // we manage redraws via dirty flag - ebiten.SetTPS(cfg.Performance.TPS) + applyTPS(cfg.Performance.TPS) ebiten.SetWindowClosingHandled(true) // intercept red X — handled in Update if err := ebiten.RunGame(game); err != nil && err != ebiten.Termination { @@ -764,6 +804,13 @@ func (g *Game) Draw(screen *ebiten.Image) { } if !g.needsRender() { + // Nothing changed — but still re-present the last frame. Ebitengine + // skips the GPU present when Draw() leaves screen untouched, which on + // macOS 14+ strands its CAMetalDisplayLink callback thread and + // deadlocks the main thread in QuartzCore update_link on the next + // display reconfiguration (wake from display sleep). Compositing every + // frame keeps the present loop alive. See [investigation] note. + g.renderer.Recompose(screen) return } // Sync transient status bar fields from live game state before rendering. @@ -856,6 +903,11 @@ func (g *Game) Draw(screen *ebiten.Image) { // Layout returns the physical screen size for HiDPI rendering. func (g *Game) Layout(outsideW, outsideH int) (int, int) { + // Record the logical size ebiten is rendering against so handleResize can + // commit render geometry from the same source — ebiten.WindowSize() lags + // this when the window crosses displays, which strands the render surface + // at the old size (content top-left, black gap). + g.layoutW, g.layoutH = outsideW, outsideH return int(float64(outsideW) * g.dpi), int(float64(outsideH) * g.dpi) } diff --git a/renderer/font.go b/renderer/font.go index d0be704..7ed2f42 100644 --- a/renderer/font.go +++ b/renderer/font.go @@ -58,6 +58,15 @@ func NewFontRenderer(ttfData []byte, size float64, fallbackData ...[]byte) (*Fon if cellH < 1 { cellH = int(size + 0.5) } + // Floor: a glyph cell must never be zero-sized. Callers clamp font size at + // the config boundary, but guard here too — every layout calculation divides + // by these, so a 0 would panic with integer divide-by-zero. + if cellW < 1 { + cellW = 1 + } + if cellH < 1 { + cellH = 1 + } // Baseline: approximately 80% of cell height is a reasonable default. baseline := int(float64(cellH)*0.80 + 0.5) diff --git a/renderer/renderer.go b/renderer/renderer.go index ff18928..3e858c6 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -256,9 +256,22 @@ func (r *Renderer) ClearPaneCache() { r.paneCache = make(map[*pane.Pane]*paneCacheEntry) } -// Offscreen returns the last rendered image. Used by Draw() to re-blit -// without a full redraw when the frame is not dirty. -func (r *Renderer) Offscreen() *ebiten.Image { return r.offscreen } +// Recompose re-composites the last rendered frame onto screen without redrawing +// any content. The offscreen, block, and modal layers are all retained between +// frames, so this reproduces the previous frame exactly. +// +// Draw() calls this on frames where nothing changed. Ebitengine skips the GPU +// present entirely when Draw() leaves screen untouched; on macOS 14+ that +// strands the CAMetalDisplayLink callback thread (it blocks forever waiting for +// nextDrawable), which deadlocks the main thread inside QuartzCore's update_link +// on the next display reconfiguration — i.e. every wake from display sleep. +// Compositing every frame keeps the present loop alive and avoids the hang. +func (r *Renderer) Recompose(screen *ebiten.Image) { + if r.offscreen == nil { + return + } + r.composeFinal(screen) +} // SetSize (re)allocates the offscreen and blocks layer images when the window resizes. func (r *Renderer) SetSize(w, h int) { diff --git a/terminal/buffer.go b/terminal/buffer.go index df33fca..c58b8fc 100644 --- a/terminal/buffer.go +++ b/terminal/buffer.go @@ -175,6 +175,15 @@ type ScreenBuffer struct { // maxScrollback is the maximum number of lines to keep in scrollback history. // maxBlocks caps the number of completed command blocks retained (0 = unlimited). func NewScreenBuffer(rows, cols, maxScrollback, maxBlocks int, fg, bg color.RGBA, palette [16]color.RGBA) *ScreenBuffer { + // A buffer with fewer than one row/col is nonsensical and makes the cell + // slice allocations below panic with a negative length. Floor at 1 so no + // caller (startup, session restore, resize) can produce a degenerate buffer. + if rows < 1 { + rows = 1 + } + if cols < 1 { + cols = 1 + } sb := &ScreenBuffer{ Rows: rows, Cols: cols, diff --git a/terminal/buffer_dims_test.go b/terminal/buffer_dims_test.go new file mode 100644 index 0000000..dc6efd5 --- /dev/null +++ b/terminal/buffer_dims_test.go @@ -0,0 +1,32 @@ +package terminal + +import "testing" + +// NewScreenBuffer must never produce a buffer with fewer than 1 row/col — a +// negative dim (e.g. from a bad [window] rows/columns config) would otherwise +// panic in the cell-slice allocations. +func TestNewScreenBuffer_FloorsDimensions(t *testing.T) { + cases := []struct { + name string + rows, cols int + }{ + {"negative both", -5, -3}, + {"zero both", 0, 0}, + {"negative rows", -1, 80}, + {"zero cols", 24, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sb := NewScreenBuffer(c.rows, c.cols, 100, 0, testFG, testBG, testPalette) + if sb.Rows < 1 || sb.Cols < 1 { + t.Fatalf("NewScreenBuffer(%d,%d) = Rows %d, Cols %d; want both >= 1", c.rows, c.cols, sb.Rows, sb.Cols) + } + if len(sb.Cells) != sb.Rows { + t.Errorf("Cells rows = %d, want %d", len(sb.Cells), sb.Rows) + } + if len(sb.dirty) != sb.Rows { + t.Errorf("dirty len = %d, want %d", len(sb.dirty), sb.Rows) + } + }) + } +} diff --git a/terminal/lifecycle_test.go b/terminal/lifecycle_test.go new file mode 100644 index 0000000..44a2db7 --- /dev/null +++ b/terminal/lifecycle_test.go @@ -0,0 +1,24 @@ +package terminal + +import "testing" + +// Close must cancel the terminal's lifecycle context so in-flight CWD/foreground +// query goroutines (and their lsof/ps subprocesses) stop instead of lingering +// until the whole app exits. +func TestClose_CancelsContext(t *testing.T) { + term := New(TerminalConfig{Rows: 24, Cols: 80}) + + select { + case <-term.ctx.Done(): + t.Fatal("context cancelled before Close") + default: + } + + term.Close() + + select { + case <-term.ctx.Done(): + default: + t.Fatal("context not cancelled after Close") + } +} diff --git a/terminal/pty.go b/terminal/pty.go index c3636ed..22a86d5 100644 --- a/terminal/pty.go +++ b/terminal/pty.go @@ -5,9 +5,9 @@ import ( "log" "os" "os/exec" - "runtime" "sync/atomic" "syscall" + "time" "unsafe" "github.com/creack/pty" @@ -83,10 +83,13 @@ func (m *PTYManager) StartReader(parser *Parser, buf *ScreenBuffer, paused *atom for { n, err := m.ptmx.Read(scratch) if n > 0 { - // Spin-wait while paused so resize can acquire the buffer lock - // without contention from the reader goroutine. + // Wait while paused so resize can acquire the buffer lock without + // contention from the reader goroutine. Poll with a short sleep + // rather than a tight spin — idle suspension can hold the pause + // for a long time, and busy-spinning there burns CPU. No lock is + // held here, so resize lock-handoff is unaffected. for paused.Load() { - runtime.Gosched() + time.Sleep(2 * time.Millisecond) } buf.Lock() parser.Feed(scratch[:n]) diff --git a/terminal/server_backend.go b/terminal/server_backend.go index a8d6232..173c763 100644 --- a/terminal/server_backend.go +++ b/terminal/server_backend.go @@ -5,8 +5,8 @@ import ( "encoding/json" "fmt" "net" - "runtime" "sync/atomic" + "time" "github.com/studiowebux/zurm/zserver" ) @@ -134,8 +134,11 @@ func (b *ServerBackend) StartReader(parser *Parser, buf *ScreenBuffer, paused *a } switch msg.Type { case zserver.MsgOutput: + // Poll with a short sleep rather than a tight spin: idle + // suspension can hold the pause for a long time, and busy-spinning + // there burns CPU. No lock is held here. for paused.Load() { - runtime.Gosched() + time.Sleep(2 * time.Millisecond) } buf.Lock() parser.Feed(msg.Payload) diff --git a/terminal/terminal.go b/terminal/terminal.go index df9d728..86dcdc6 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -70,6 +70,13 @@ type Terminal struct { // ShellIntCh receives OSC 133 event codes (A/C/D) from the parser. // Drained by the game loop to update the foreground process name. ShellIntCh chan byte + + // ctx is the terminal's lifecycle context, cancelled by Close(). Background + // query goroutines (CWD/foreground via lsof/ps) derive from it so closing a + // pane promptly kills its in-flight query subprocesses and unblocks any + // pending channel sends, instead of lingering until the whole app exits. + ctx context.Context + cancel context.CancelFunc } // New creates a Terminal from the given config. @@ -82,6 +89,7 @@ func New(tc TerminalConfig) *Terminal { cur.EnableBlink() } + ctx, cancel := context.WithCancel(context.Background()) return &Terminal{ Buf: buf, Cursor: cur, @@ -91,6 +99,8 @@ func New(tc TerminalConfig) *Terminal { BellCh: make(chan struct{}, 4), ForegroundProcCh: make(chan string, 4), ShellIntCh: make(chan byte, 4), + ctx: ctx, + cancel: cancel, } } @@ -353,6 +363,9 @@ func (t *Terminal) Dead() <-chan struct{} { // Close cleans up the PTY. func (t *Terminal) Close() { + if t.cancel != nil { + t.cancel() // cancel in-flight CWD/foreground query subprocesses + } if t.pty != nil { t.pty.Close() } @@ -392,10 +405,11 @@ func (t *Terminal) HasOSC133() bool { return t.osc133Active.Load() } // QueryCWD performs a one-shot CWD query via lsof and sends the result // to CwdCh. No-op when the shell already delivers CWD via OSC 7 (osc7Active) // or when the terminal is idle-suspended. -func (t *Terminal) QueryCWD(ctx context.Context) { +func (t *Terminal) QueryCWD() { if t.paused.Load() || t.osc7Active.Load() { return } + ctx := t.ctx pid := t.Pid() if pid <= 0 { return @@ -427,14 +441,14 @@ func (t *Terminal) QueryCWD(ctx context.Context) { // QueryForeground performs a one-shot foreground process query and sends // the result to ForegroundProcCh if it changed. Safe to call from a goroutine. -func (t *Terminal) QueryForeground(ctx context.Context) { +func (t *Terminal) QueryForeground() { if t.paused.Load() { return } - name := t.foregroundProcessName(ctx) + name := t.foregroundProcessName() select { case t.ForegroundProcCh <- name: - case <-ctx.Done(): + case <-t.ctx.Done(): } } @@ -442,10 +456,11 @@ func (t *Terminal) QueryForeground(ctx context.Context) { // Returns an empty string when the shell itself is the foreground process or on error. // When the foreground process is ssh, returns "ssh\n" so the caller // can extract the SSH destination from the second newline-delimited field. -func (t *Terminal) foregroundProcessName(ctx context.Context) string { +func (t *Terminal) foregroundProcessName() string { if t.pty == nil { return "" } + ctx := t.ctx pgid, err := t.pty.ForegroundPgid() if err != nil || pgid <= 0 { return "" @@ -518,15 +533,15 @@ func parseSSHHost(args string) string { // RefreshForeground immediately queries the foreground process and sends the // result to ForegroundProcCh. Called on focus switch so the status bar updates // right away without waiting for the next 1-second poll tick. -func (t *Terminal) RefreshForeground(ctx context.Context) { +func (t *Terminal) RefreshForeground() { if !t.tcfg.ShowProcess { return } go func() { - name := t.foregroundProcessName(ctx) + name := t.foregroundProcessName() select { case t.ForegroundProcCh <- name: - case <-ctx.Done(): + case <-t.ctx.Done(): } }() } diff --git a/wake_darwin.go b/wake_darwin.go index 2cbf1f3..0832789 100644 --- a/wake_darwin.go +++ b/wake_darwin.go @@ -104,10 +104,10 @@ func sleepWatcher() { // Only intervene on wake when we were the ones who zeroed TPS. if screenSleeping.Load() != 0 && C.consumeWakeFlag() != 0 { screenSleeping.Store(0) - // Restore a baseline TPS so the Ebiten loop starts running again. - // handleFocus will correct it to the user-configured value via - // unsuspendAndRedraw() as soon as it processes screenWakeFlag. - ebiten.SetTPS(60) + // Restore the user-configured TPS so the Ebiten loop starts running + // again. handleFocus re-applies it via unsuspendAndRedraw() as soon + // as it processes screenWakeFlag — this just unfreezes the loop. + restoreConfiguredTPS() screenWakeFlag.Store(1) } }