From 262c94a24d5273d9b5dc5788e541d9b23a5bbfa5 Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Sun, 17 May 2026 17:15:25 -0400 Subject: [PATCH 01/11] fix: prevent wake-from-display-sleep deadlock and restore TPS on wake zurm could hang permanently after a display slept and woke (lid close in clamshell, monitor power-cycle, display idle-timeout), requiring a force-quit. Root cause: when idle, zurm skipped the GPU present (needsRender early-return with SetScreenClearedEveryFrame(false)). On macOS 14+ Ebitengine drives rendering via CAMetalDisplayLink, whose delegate callback blocks on a channel until nextDrawable() is called. Skipping the present strands that callback thread; the next display reconfiguration then deadlocks the main thread inside QuartzCore's update_link, which waits on the stranded display-link machinery. - Draw() now re-composites the last frame every tick (Renderer.Recompose) instead of returning without touching screen, so Ebitengine keeps presenting and the display-link callback thread is never stranded. - sleepWatcher restored a hardcoded TPS=60 on wake instead of the configured rate; it now restores the user-configured tps via applyTPS/configuredTPS. Bump version to v1.5.4; add changelog entry. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- Makefile | 2 +- docs/__changelog__/releases/1.5.4.md | 10 ++++++++ game_misc.go | 2 +- main.go | 38 +++++++++++++++++++++++++++- renderer/renderer.go | 19 +++++++++++--- wake_darwin.go | 8 +++--- 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 docs/__changelog__/releases/1.5.4.md 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/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md new file mode 100644 index 0000000..13c37c4 --- /dev/null +++ b/docs/__changelog__/releases/1.5.4.md @@ -0,0 +1,10 @@ +--- +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. diff --git a/game_misc.go b/game_misc.go index c752b5d..f808914 100644 --- a/game_misc.go +++ b/game_misc.go @@ -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 diff --git a/main.go b/main.go index ab7d697..fe8ff9a 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 @@ -510,7 +539,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 +793,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. 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/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) } } From 244fbd1f2104d1ce932d727e6751ce98e344dcce Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Mon, 18 May 2026 13:26:35 -0400 Subject: [PATCH 02/11] fix: prevent panic when window resizes to zero size on display sleep A sleeping or disconnected display reports a 0x0 window size (glfw: "Cannot query content scale without screen"). handleResize committed those zeros and Renderer.SetSize called ebiten.NewImage(0, 0), which panics. Skip the resize when width or height is non-positive; layout recommits automatically once a real display is back. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- docs/__changelog__/releases/1.5.4.md | 2 ++ game_drain.go | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md index 13c37c4..946f61d 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -8,3 +8,5 @@ date: "2026-05-17T00:00:00Z" - **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. diff --git a/game_drain.go b/game_drain.go index 8d2793e..d6c6e15 100644 --- a/game_drain.go +++ b/game_drain.go @@ -76,6 +76,13 @@ func (g *Game) handleResize() { } w, h := ebiten.WindowSize() + // 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. Skip the resize — geometry + // recommits naturally once a real display is back. + if w <= 0 || h <= 0 { + return + } dpi := g.monitorDPI() dpiChanged := dpi != g.dpi if w == g.winW && h == g.winH && !dpiChanged { From 250123e6c13fbd145cc88a150690eadb2dfb0d24 Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 13:02:47 -0400 Subject: [PATCH 03/11] fix: keep per-pane scroll position across focus and tab changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smooth-scroll animation state (smoothScrollPos/Target) was global to the Game, and tickSmoothScroll wrote it into the active pane's ViewOffset every frame. setFocus never resynced it and tab switches bypass focus entirely, so focusing another pane snapped it to wherever the previous pane was scrolled — a random up/down jump. Track the pane the animation last drove (smoothScrollPane); tickSmoothScroll now resyncs to the newly focused pane's actual ViewOffset on change instead of forcing the stale position. Single-point fix in the consumer covers every focus path (click, focusDir, goBack, tab switch). Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- docs/__changelog__/releases/1.5.4.md | 2 ++ game_drain.go | 20 ++++++++++++++++++-- main.go | 5 +++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md index 946f61d..7e524eb 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -10,3 +10,5 @@ date: "2026-05-17T00:00:00Z" - **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. diff --git a/game_drain.go b/game_drain.go index d6c6e15..7320652 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 diff --git a/main.go b/main.go index fe8ff9a..93e606b 100644 --- a/main.go +++ b/main.go @@ -173,8 +173,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 From 1adc20228bae71ab77d587a89de2896f63e0698e Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 17:39:30 -0400 Subject: [PATCH 04/11] fix: commit render geometry from Layout size, not WindowSize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the window to another display left content stranded at its old size in the top-left with the rest of the window black. The ebiten screen is sized from Layout(outsideW, outsideH), but the render surface was sized from ebiten.WindowSize(), which lags the framebuffer that drives Layout on a cross-display move — so the screen grew while the render image stayed small, leaving a black gap. Capture the logical size ebiten passes to Layout (layoutW/H) and source handleResize geometry from it via logicalSize(). Render geometry now comes from the same dimensions ebiten renders against, so the image can no longer diverge from the screen. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- docs/__changelog__/releases/1.5.4.md | 2 ++ game_drain.go | 4 ++-- game_lifecycle.go | 14 +++++++++++++- main.go | 10 ++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md index 7e524eb..0653774 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -12,3 +12,5 @@ date: "2026-05-17T00:00:00Z" - **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. diff --git a/game_drain.go b/game_drain.go index 7320652..4cf4af1 100644 --- a/game_drain.go +++ b/game_drain.go @@ -74,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. @@ -91,7 +91,7 @@ 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. Skip the resize — geometry 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/main.go b/main.go index 93e606b..682b322 100644 --- a/main.go +++ b/main.go @@ -145,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. @@ -893,6 +898,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) } From 9591b9944d7232e65ffeb65a71e56feeb4833697 Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 17:49:10 -0400 Subject: [PATCH 05/11] fix: guard resize on live window size, not just Layout size The display-move fix repointed handleResize geometry to logicalSize() (cached Layout dims), which also moved the zero-size panic guard off the live ebiten.WindowSize() signal. logicalSize() can briefly return a stale non-zero size after a display disconnect before Layout is called with 0. Guard on both the live window size (authoritative screen-attached signal) and the commit dims so the no-screen protection can't be bypassed. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- game_drain.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/game_drain.go b/game_drain.go index 4cf4af1..3783eaa 100644 --- a/game_drain.go +++ b/game_drain.go @@ -94,9 +94,11 @@ func (g *Game) handleResize() { 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. Skip the resize — geometry - // recommits naturally once a real display is back. - if w <= 0 || h <= 0 { + // 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() From af25bf33223e07763715a8293952bd551c07c0fe Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:14:22 -0400 Subject: [PATCH 06/11] fix: clamp config font size to prevent divide-by-zero crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A font.size of zero or below collapsed the glyph cell to 0x0 (cellW/cellH computed from size), and every layout calculation divides by those metrics — crashing with an integer divide-by-zero. The interactive resize clamped the size but the config path did not, so a bad value in config crashed at startup, and a typo during a live config edit crashed via hot-reload. - Move the renderable bounds to config.MinFontSize/MaxFontSize as the single source of truth; adjustFontSize now references them (drops the duplicate constants in main). - clampFontSize() runs in Load and LoadWithMeta right after decode (before the error return, since startup uses the cfg even on error), so startup, hot-reload, and interactive resize all see a valid size. - Floor cellW/cellH at 1 in NewFontRenderer as a final guard. - Add config clamp test; document the 6-72 range in the changelog, config reference, and generated config template. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- config/config.go | 26 +++++++++++++++++++++- config/fontsize_clamp_test.go | 28 ++++++++++++++++++++++++ docs/__changelog__/releases/1.5.4.md | 2 ++ docs/getting-started/02-configuration.md | 2 +- game_misc.go | 16 +++++--------- renderer/font.go | 9 ++++++++ 6 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 config/fontsize_clamp_test.go diff --git a/config/config.go b/config/config.go index c66011d..a5e2084 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. @@ -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) + clampFontSize(&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) + clampFontSize(&cfg) if err != nil { return &cfg, meta, fmt.Errorf("config: %w", err) } @@ -534,6 +545,19 @@ func LoadWithMeta() (*Config, toml.MetaData, error) { return &cfg, meta, nil } +// 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..b7da791 --- /dev/null +++ b/config/fontsize_clamp_test.go @@ -0,0 +1,28 @@ +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) + } + }) + } +} diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md index 0653774..f7e2160 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -14,3 +14,5 @@ date: "2026-05-17T00:00:00Z" - **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. diff --git a/docs/getting-started/02-configuration.md b/docs/getting-started/02-configuration.md index 7640c02..30db058 100644 --- a/docs/getting-started/02-configuration.md +++ b/docs/getting-started/02-configuration.md @@ -14,7 +14,7 @@ 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 diff --git a/game_misc.go b/game_misc.go index f808914..2c388ac 100644 --- a/game_misc.go +++ b/game_misc.go @@ -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/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) From 31f1e04e72d39207744899cf8b6bd909ffdfa2da Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:17:17 -0400 Subject: [PATCH 07/11] fix: reset click-count tracking on focus change Clicking to select in one pane then quickly clicking the same cell in a newly focused pane could register as a double/triple click (word/line select), because setFocusNoHistory left LastClickRow/Col/ClickCount intact. Reset them on focus change; a sentinel cell ensures the next click is never treated as a continuation of the previous pane's click sequence. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- game_panes.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/game_panes.go b/game_panes.go index 2d6c784..21f48ce 100644 --- a/game_panes.go +++ b/game_panes.go @@ -225,6 +225,12 @@ 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) if g.search.State.Open { From 627258f214e009a801db3bd348b50832b74f750d Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:23:38 -0400 Subject: [PATCH 08/11] fix: validate window dims to prevent buffer-alloc crash A window.columns/rows of zero or below flowed straight from config into NewScreenBuffer, whose cell-slice allocations panic with a negative length (startup crash) or produce a degenerate empty buffer (zero). - Generalize the post-decode config clamp into normalize(), which now also bounds window.columns/rows to >= 1 and padding to >= 0 (logged). - NewScreenBuffer floors rows/cols at 1 as defense in depth, protecting the session-restore and resize callers too. - Add config and buffer-dimension tests; note the bounds in the changelog, config reference, and generated template. Also backfill changelog entries for the font-size clamp and cross-pane click-count fixes. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- config/config.go | 36 ++++++++++++++++++++---- config/fontsize_clamp_test.go | 25 ++++++++++++++++ docs/__changelog__/releases/1.5.4.md | 4 +++ docs/getting-started/02-configuration.md | 6 ++-- terminal/buffer.go | 9 ++++++ terminal/buffer_dims_test.go | 32 +++++++++++++++++++++ 6 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 terminal/buffer_dims_test.go diff --git a/config/config.go b/config/config.go index a5e2084..fea64ca 100644 --- a/config/config.go +++ b/config/config.go @@ -45,9 +45,9 @@ size = 15 # points, clamped to 6–72 # -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 @@ -514,7 +514,7 @@ func Load() (*Config, error) { } meta, err := toml.DecodeFile(path, &cfg) - clampFontSize(&cfg) + normalize(&cfg) if err != nil { return &cfg, err } @@ -537,7 +537,7 @@ func LoadWithMeta() (*Config, toml.MetaData, error) { cfg := Defaults meta, err := toml.DecodeFile(path, &cfg) - clampFontSize(&cfg) + normalize(&cfg) if err != nil { return &cfg, meta, fmt.Errorf("config: %w", err) } @@ -545,6 +545,32 @@ 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. diff --git a/config/fontsize_clamp_test.go b/config/fontsize_clamp_test.go index b7da791..65594e3 100644 --- a/config/fontsize_clamp_test.go +++ b/config/fontsize_clamp_test.go @@ -26,3 +26,28 @@ func TestClampFontSize(t *testing.T) { }) } } + +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 index f7e2160..0f88d93 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -16,3 +16,7 @@ date: "2026-05-17T00:00:00Z" - **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. diff --git a/docs/getting-started/02-configuration.md b/docs/getting-started/02-configuration.md index 30db058..43d27a1 100644 --- a/docs/getting-started/02-configuration.md +++ b/docs/getting-started/02-configuration.md @@ -19,9 +19,9 @@ size = 15 # font size in points (clamped to 6–72) # 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/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) + } + }) + } +} From 48d6d83665518f434a846a95b4b53f1e5f81e03b Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:36:59 -0400 Subject: [PATCH 09/11] fix: clamp restored active-tab index on session restore A session file with a negative or out-of-range active_tab (corruption or manual editing) made initialTabs[initialActive] panic at startup, so the app could not launch until the file was deleted. Clamp the restored index to [0, len-1]. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- docs/__changelog__/releases/1.5.4.md | 2 ++ main.go | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/__changelog__/releases/1.5.4.md b/docs/__changelog__/releases/1.5.4.md index 0f88d93..8272e1d 100644 --- a/docs/__changelog__/releases/1.5.4.md +++ b/docs/__changelog__/releases/1.5.4.md @@ -20,3 +20,5 @@ date: "2026-05-17T00:00:00Z" - **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/main.go b/main.go index 682b322..f0e0cda 100644 --- a/main.go +++ b/main.go @@ -393,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 } } From 0bb2db6d15e2648047e77b06b50df24d6f5a981d Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:46:27 -0400 Subject: [PATCH 10/11] fix: cancel terminal query subprocesses on pane close QueryCWD/QueryForeground/RefreshForeground ran lsof/ps via the app-wide g.ctx and sent to buffered channels. When a pane closed, nothing cancelled its in-flight query subprocess or unblocked a full-buffer send, so they lingered until the whole app exited. Give Terminal its own lifecycle context cancelled in Close(); the query methods derive from it (dropping the now-redundant ctx parameter). Closing a pane now promptly kills its query subprocess and unblocks its sends. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- game_drain.go | 6 +++--- game_misc.go | 4 ++-- game_panes.go | 2 +- game_tabs.go | 2 +- terminal/lifecycle_test.go | 24 ++++++++++++++++++++++++ terminal/terminal.go | 31 +++++++++++++++++++++++-------- 6 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 terminal/lifecycle_test.go diff --git a/game_drain.go b/game_drain.go index 3783eaa..fb3b606 100644 --- a/game_drain.go +++ b/game_drain.go @@ -473,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: } @@ -487,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_misc.go b/game_misc.go index 2c388ac..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) } diff --git a/game_panes.go b/game_panes.go index 21f48ce..48b51bb 100644 --- a/game_panes.go +++ b/game_panes.go @@ -232,7 +232,7 @@ func (g *Game) setFocusNoHistory(p *pane.Pane) { 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/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/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(): } }() } From 11c8df7e005607f763d931110bc79e416e3a215d Mon Sep 17 00:00:00 2001 From: Tommy Gingras Date: Fri, 29 May 2026 18:50:54 -0400 Subject: [PATCH 11/11] fix: poll instead of busy-spin while PTY reader is paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PTY readers spun `for paused.Load() { runtime.Gosched() }` after reading a chunk, to let resize grab the buffer lock. The pause is brief for resize, but idle suspension also sets it for a long time, so a paused pane still receiving output burned CPU — defeating the idle power-saving it runs under. The wait holds no lock, so polling with a 2ms sleep keeps resize lock-handoff unaffected while dropping idle CPU dramatically. Powered by MDPlanner.dev X Cerveau.dev Co-Authored-By: Claude --- terminal/pty.go | 11 +++++++---- terminal/server_backend.go | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) 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)