A tour of Finallyshell's source: keeping UI state on one thread, coalescing goroutine wakeups, and rejecting oversized background images before a full decode.
· 7 min read

Finallyshell source walkthrough: Go concurrency patterns in a FinalShell-style SSH client


Desktop apps have an awkward concurrency boundary: the UI owns mutable view state, while SSH handshakes, SFTP listings and file transfers can block for seconds. Let background work write that state directly and intermittent races follow.

Finallyshell draws the boundary in the package comment at the top of internal/app/app.go: views read state on the UI thread, while goroutines that connect, poll and transfer send state changes back through App.post.

It’s an SSH client with FinalShell’s layout, built on the MyGo UI toolkit. The interesting part here is how the codebase maintains that boundary with small tools: sync/atomic, a channel with a default branch, and an integer counter.

State mutation goes through one function

The UI reads app state without locks. That only works if nothing else writes it concurrently, so the write path has to be narrow enough to see.

The Platform interface in internal/app/app.go is where it narrows. Its first method:

// Post runs fn on the UI thread, then draws every open window again.
// It returns without waiting.
Post(fn func())

The native dialog methods document the other side of the boundary. OpenFiles says that it blocks and is called from a goroutine; the other file and folder pickers carry the same warning in shorter form. Blocking is part of the interface contract, not something callers discover later in a stack trace.

internal/app/archive.go shows the round trip. pickAndUploadArchive checks preconditions on the UI thread, spawns a goroutine for the dialog, then hands the result back:

go func() {
	paths, err := a.plat.OpenFiles(title, true)
	if err != nil || len(paths) == 0 {
		return
	}
	a.post(func() { a.uploadAs(s, paths, dir, true) })
}()

Guard clause before the go statement. Slow call off-thread. State change back inside a.post. If you’ve written a Bubble Tea app, it’s the same discipline as returning a tea.Cmd instead of reaching into the model from a goroutine; see our Riptide walkthrough for that flavour of it.

Making Platform an interface buys the testing seam too. The doc comment: “The program passes the real one; tests pass a fake.” Native file dialogs can’t run under go test, so they don’t.

Coalescing wakeups with a buffered channel and default

Session tabs show the logo of the coding agent running in that pane’s shell, Claude Code or Codex, read from the server’s /proc by the shell’s follower goroutine. The shared state between that goroutine and the UI thread fits in three fields, in internal/app/agenticon.go:

type agentWatch struct {
	on    atomic.Bool
	turns atomic.Int64
	wake  chan struct{}
}

on is the setting. The UI thread writes it, the follower reads it. An atomic.Bool because there’s a single flag and nothing else to protect.

turns counts how many times on has flipped. A bool alone isn’t enough because the follower normally reports an agent only when the value differs from its previous report, and the UI thread may not have processed that report yet. Changing the setting clears the tab, so the next successful read must be sent even if the detected agent has not changed. Comparing the current turn with the last reported turn provides that extra signal.

wake is the request channel, and poke is the bit worth stealing:

func (w *agentWatch) poke() {
	select {
	case w.wake <- struct{}{}:
	default:
	}
}

Send if there’s room, drop it otherwise. Callers never block, and ten pokes arriving while the follower is busy collapse into one pending wakeup. For a “go re-read the current state” signal, where only the newest reading matters, dropping duplicates is the correct behaviour rather than a compromise.

shellKey decides when to poke. Enter hands off to shellCommandSent. ui.Ctrl with C, D or Z pokes directly, but only after checking w.on.Load(), since one of those keys probably just ended a program. Between pokes, agentPoll covers the case nobody typed anything: a package-level var set to 10 seconds, a var and not a const because, per the comment, tests shorten it. Small thing, and it’s the difference between a 10-second test and a fast one.

applyAgentIcons updates the two atomics in this order:

w.turns.Add(1) // before on, which the follower reads after it
w.on.Store(on)

The follower loads turns and then on. These are still two separate atomic values, not one atomic snapshot, so the safe lesson is narrower than “ordering two atomics makes a compound state atomic.” The protocol uses the counter to make a setting change trigger a fresh report; if your correctness depends on two fields always being observed as one value, protect them with a mutex or publish one immutable value through a single atomic operation. The Go memory model is worth keeping close when reviewing code like this.

Generation counters kill stale async results

Background image loading in internal/app/appearance.go has the race everyone meets eventually: the user picks image A, changes their mind, picks image B, and A finishes decoding last. The appearance struct handles it with one int:

gen int // counts the reads, so that a stale one is dropped

Bump on start, capture the value in the goroutine, compare on completion, throw away mismatches. The same four steps work for search-as-you-type, autocomplete, and anything else where a late response is worse than no response.

The struct also keeps the slow work behind a field:

read func(path string) (*ui.Bitmap, error)

It falls back to readImage when nil, and the comment on it reads “Tests hold it up.” A nil-able function field lets a test stall decoding mid-flight and drive the stale-result path on purpose, deterministically. Cheaper than declaring an interface for one method, and it leaves the production path untouched.

Colour schemes are Ghostty themes by name. loadTheme tries terminal.GhosttyTheme(name), falls back to config.DefaultTermTheme, and returns nil as the last resort, which makes the terminal follow the window instead.

Bounding image decoding before it happens

internal/app/bgimage.go is a short lesson in not trusting a file the user picked. Three constants set the budget: maxBackgroundSize at 64 << 20 bytes, maxBackgroundPixels at 100 million, and maxBackgroundSide at 3840, which the comment notes is a 4K display’s width.

The file read uses the io.LimitReader off-by-one:

data, err := io.ReadAll(io.LimitReader(f, maxBackgroundSize+1))
if err != nil {
	return nil, err
}
if len(data) > maxBackgroundSize {
	return nil, errImageTooLarge
}

That +1 is the whole trick. With a plain LimitReader(f, max) you get exactly max bytes back and no way to distinguish a file that fits from one you silently truncated.

Decoding gets the same treatment. image.DecodeConfig reads the dimensions without decoding the whole image first:

cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
	return nil, fmt.Errorf("%w: %v", errImageFormat, err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
	return nil, errImageFormat
}
if int64(cfg.Width)*int64(cfg.Height) > maxBackgroundPixels*1_000_000 {
	return nil, errImageTooLarge
}

The int64 conversions before multiplying matter. Two int dimensions can otherwise overflow on a 32-bit platform and slip past the check. The source budgets four bytes per decoded pixel, so 100 million pixels is roughly 400MB before overhead. decodeBackground then scales accepted images down to at most maxSide in either dimension, using golang.org/x/image/draw imported as xdraw.

One more habit worth copying from that snippet: %w on the sentinel, %v on the decoder’s error. Callers get errors.Is(err, errImageFormat) without whatever the PNG decoder felt like saying becoming part of the API.

Typed errors at the UI boundary

transferErrorText in internal/app/archive.go is where a failed transfer becomes a sentence a person reads:

var bad *remotefs.WindowsNameError
var damaged *remotefs.DamagedArchiveError
switch {
case errors.As(err, &damaged):
	return locales.ArchiveDamaged(damaged.Err.Error())
case errors.As(err, &bad):
	return locales.ArchiveWindowsName(bad.Name)
case errors.Is(err, remotefs.ErrNoShell):
	return locales.ArchiveNoShell()
}
return err.Error()

errors.As for the errors carrying data the message needs (bad.Name, damaged.Err), errors.Is for the sentinel that carries nothing, and a fallback to err.Error() so an unrecognised failure still reaches the user rather than vanishing into “something went wrong”. remotefs owns the error types, the app layer owns the wording. That split is exactly why WindowsNameError can carry a Name field instead of a pre-formatted English string, and why translating the message doesn’t mean touching the filesystem package.

The archive feature has a bit of product judgement baked into archivable as well: more than one selected name qualifies, or a single entry that’s a directory and not a symlink. One file doesn’t, because tarring one file buys you nothing. The comment in downloadMenuItems says as much, right at the call site.

What ties these patterns together is that each one is small: an extra +1 on a LimitReader, an integer generation counter, or a default case on a channel send. The source also documents why each piece exists, which matters as much as the code. For more source-driven reading, see our tour of the Go repository. Lazygit is another useful example of event-loop discipline, this time in a terminal UI.