Skip to content

fix: prevent wake-from-display-sleep deadlock and restore TPS on wake#67

Merged
studiowebux merged 11 commits into
mainfrom
fix/sleep-watcher-tps-restore
Jun 2, 2026
Merged

fix: prevent wake-from-display-sleep deadlock and restore TPS on wake#67
studiowebux merged 11 commits into
mainfrom
fix/sleep-watcher-tps-restore

Conversation

@studiowebux

Copy link
Copy Markdown
Owner

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 sample showed the main thread frozen 100% in glfwPollEvents → 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 calls nextDrawable().

zurm's idle optimization — needsRender() early-return in Draw() plus SetScreenClearedEveryFrame(false) — leaves screen untouched when idle, so Ebiten skips the GPU present entirely. nextDrawable() is then never called and the CAMetalDisplayLink callback 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's update_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 calls Renderer.Recompose(screen) on non-dirty frames — re-composites the retained offscreen/block/modal layers onto screen so Ebiten always presents and the display-link callback thread is never stranded. The expensive offscreen content re-render stays gated by needsRender().
  • sleepWatcher restored a hardcoded SetTPS(60) on wake instead of the configured rate; it now restores the user-configured tps via applyTPS / restoreConfiguredTPS / a configuredTPS atomic.
  • Version bumped to v1.5.4; changelog entry added.

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.
  • Needs manual verification: a real lid-close/clamshell sleep-wake cycle.

🤖 Powered by MDPlanner.dev X Cerveau.dev

studiowebux and others added 11 commits May 17, 2026 17:15
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>
@studiowebux studiowebux merged commit 7cdc581 into main Jun 2, 2026
2 checks passed
@studiowebux studiowebux deleted the fix/sleep-watcher-tps-restore branch June 2, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant