fix: prevent wake-from-display-sleep deadlock and restore TPS on wake#67
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
zurm hangs permanently after a display sleeps and wakes — lid close in clamshell mode, external monitor power-cycle, or display idle-timeout — requiring a force-quit. macOS marks the process "not responding"; an Activity Monitor
sampleshowed the main thread frozen 100% inglfwPollEvents → SkyLight notifyDisplayAdded → CA::Display::DisplayLinkItem::update_link → __psynch_cvwait.Root cause
Ebiten v2.9.9 on macOS 14+ renders via
CAMetalDisplayLink. Its delegate callback runs on a dedicated run-loop thread and blocks on an unbuffered channel until the render loop callsnextDrawable().zurm's idle optimization —
needsRender()early-return inDraw()plusSetScreenClearedEveryFrame(false)— leavesscreenuntouched when idle, so Ebiten skips the GPU present entirely.nextDrawable()is then never called and theCAMetalDisplayLinkcallback thread is stranded forever. While stranded it cannot service its run loop, so the next display reconfiguration (notifyDisplayAdded, fired on every display sleep→wake) deadlocks the main thread in QuartzCore'supdate_link.This is an upstream Ebiten bug — the blocking-channel display-link design is incompatible with a client that skips presents. Confirmed unchanged in ebiten v2.10-alpha.11 (only a cgo→purego refactor); cf. ebiten #3353/#3354 partial workarounds.
Fix
Draw()now callsRenderer.Recompose(screen)on non-dirty frames — re-composites the retained offscreen/block/modal layers ontoscreenso Ebiten always presents and the display-link callback thread is never stranded. The expensive offscreen content re-render stays gated byneedsRender().sleepWatcherrestored a hardcodedSetTPS(60)on wake instead of the configured rate; it now restores the user-configuredtpsviaapplyTPS/restoreConfiguredTPS/ aconfiguredTPSatomic.Tradeoff
zurm now presents every frame even when idle (standard Ebiten behavior). Idle CPU should be re-measured via Cmd+I after a sleep/wake cycle.
Testing
go build,go vet,staticcheck,gosec -severity medium,govulncheck— all pass.go test ./...— all packages pass.🤖 Powered by MDPlanner.dev X Cerveau.dev