Skip to content

fix(app): don't kill the renderer when a server leaves the list - #219

Merged
quickbeard merged 1 commit into
mainfrom
sandbox-tab-crash
Aug 13, 2026
Merged

quickbeard merged 1 commit into
mainfrom
sandbox-tab-crash

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Problem

Removing a sandbox that had a session tab open takes the whole window down:

fatal renderer error {
  error: 'Error: Notification server not found: sandbox:codev-work\n' +
    '    at Object.ensure [as ensureServerState] (context/notification.tsx)\n' +
    '    at Object.fn (pages/layout/project-avatar-state.ts)\n' +
    '    at get unread (pages/layout/session-tab-avatar.tsx)\n' + ...
}

Sidecar connections are published only while healthy — readySandboxConnections and readyWslConnections put a server in the list only in its ready state. So removing, restarting, or crashing one drops its key while its session tabs are still mounted.

useSessionTabAvatarState.unread then called notification.ensureServerState(server()), which throws by design on a key it cannot find. Because that happens inside a render-time memo, the throw escapes as a fatal renderer error rather than a contained failure.

Two things made it reachable:

  1. unread had no guard. The same hook already tolerates a missing connection everywhere else — hasPermissions, hasQuestions, and loading all bail when sync() is undefined. unread was the one path assuming the server still existed.
  2. Cleanup ran after the destroy. handleRemove awaited platform.sandboxes.remove(key) — which deletes the rootfs, seconds for a multi-GB sandbox — and only then called tabs.removeServer(key). The connection leaves the list the instant that resolves, so there was a guaranteed window with live tabs pointing at a dead key.

Fix

  • project-avatar-state.ts — unread gates on connection() like the sync paths do. A tab whose server is gone shows no unread marker instead of killing the window.
  • dialog-select-server.tsx — close tabs and switch the active server before awaiting either platform removal. This also makes cleanup complete: the tab-pruning effect in tabs.tsx that would otherwise collect these tabs does not discard their persisted draft state, while removeServer does.

Scope

This is an upstream bug, not sandbox-specific — WSL servers go through the identical readyWslConnections → ensureServerState path, so removing or restarting one with a tab open crashes the same way. The guard covers both. DIVERGENCES.md says to drop our row in favour of an upstream fix if one lands.

Verification

bun typecheck and bun run test:unit (657 pass, 0 fail) clean in packages/app.

No unit test added: the hook depends on the global, notification, and permission contexts, so covering it would mean standing up mocks for all three, which the repo's testing guidance steers away from. The change is a guard on an existing memo that the neighbouring memos already apply.

Not addressed here

  • Uncaught (in promise) Error: Path already exists: ".cache/codev/" appears in the same log but is unrelated — it comes from the third-party @pierre/trees path store behind the file tree, and it does not take the window down.
  • The tab-pruning effect deletes tabs for any server not currently in the list, and a sandbox leaves the list whenever it is merely restarting — so a restart can silently close tabs. Fixing that means deciding whether non-ready sandboxes should be listed as unhealthy or whether the prune should learn about known-but-not-ready servers, which is a design call rather than a patch.

🤖 Generated with Claude Code

Removing a sandbox that had a session tab open took the whole window down
with "Notification server not found: sandbox:<name>".

Sidecar connections are published only while healthy — readySandboxConnections
and readyWslConnections list a server only in its ready state — so removing,
restarting, or crashing one drops its key while its tabs are still mounted.
useSessionTabAvatarState.unread then called notification.ensureServerState on
the missing key, which throws, and the throw escaped a render-time memo.

Guard unread on the connection, the way hasPermissions/hasQuestions/loading
already guard on sync. A tab whose server is gone shows no unread marker.

handleRemove also awaited the platform removal — deleting a sandbox disk, so
seconds — before closing the tabs, guaranteeing a window where live tabs point
at a server the list no longer has. Close the tabs first; that also keeps
cleanup complete, since the tab-pruning effect that would otherwise collect
them leaves the drafts' persisted state behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 597ebab into main Aug 13, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the sandbox-tab-crash branch August 13, 2026 06:56
quickbeard pushed a commit that referenced this pull request Aug 13, 2026
Deleting a sandbox from Settings while the Work side was open killed the
renderer with "Permission server not found: sandbox:<name>".

createMemo computes eagerly, so permissionState ran the moment
useSessionTabAvatarState did. For a tab drawn after its server had already
disappeared that is during construction — before the sync()/connection()
guards downstream get a chance — and ensureServerState throws on a key it
cannot find. #219 guarded the notification lookup on the same hook but left
this one, which is reached earlier.

Gate it on connection() too, and let effectiveSide fall back to Code once the
last sandbox is deleted so the sidebar is not left sitting on a side with
nothing in it. An unloaded sandbox list stays "unknown" rather than "none",
or every launch would bounce off Work before the list arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard added a commit that referenced this pull request Aug 13, 2026
* feat(app): split the sidebar into Code and Work

Sandboxes were just another row in a list of servers, so the isolated
environment and the local machine were one undifferentiated pile. The sidebar
now opens on two tabs: Code, every non-sandbox server (local sidecar, WSL,
remote, SSH), and Work, the sandboxes.

Each side keeps its own projects, pinned and recent sessions, and its own
session tabs — the titlebar strip, its reorder and tab.prev/tab.next all
follow the visible side. Clicking a tab returns you to that side's last
session rather than opening a draft, falling back to a draft and then to the
home screen when a side has no history and nothing to open.

The Home button goes with it: it opened a draft, not a home screen, which is
exactly the behaviour the tabs replace. Its mod+b command goes too.

Switching to Work with no sandbox reuses DialogAddSandbox rather than growing
a second one, and only switches if a sandbox actually gets created — cancel
leaves you on Code. A side can now be empty (a sandbox joins the server list
only once it is ready), so the sidebar gains an empty state; every branch
under it indexes servers[0].

Logic lives in the fork-only workspace module and is unit tested; the upstream
files carry wiring only, and every section below the tabs is the same
component reading a filtered list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): survive a tab whose server is gone at construction

Deleting a sandbox from Settings while the Work side was open killed the
renderer with "Permission server not found: sandbox:<name>".

createMemo computes eagerly, so permissionState ran the moment
useSessionTabAvatarState did. For a tab drawn after its server had already
disappeared that is during construction — before the sync()/connection()
guards downstream get a chance — and ensureServerState throws on a key it
cannot find. #219 guarded the notification lookup on the same hook but left
this one, which is reached earlier.

Gate it on connection() too, and let effectiveSide fall back to Code once the
last sandbox is deleted so the sidebar is not left sitting on a side with
nothing in it. An unloaded sandbox list stays "unknown" rather than "none",
or every launch would bounce off Work before the list arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): switch to Work as soon as the sandbox is created

Creating the first sandbox from the Work gate left the app on Code until the
tabs were toggled by hand. Three things had to line up and none of them did.

The switch checked the sandbox list after the dialog closed, but that list
arrives on its own IPC event and is still empty for a beat after add()
resolves. DialogAddSandbox now reports the sandbox it created and the gate
switches on that.

effectiveSide collapsed Work to Code whenever the sandbox list was empty,
which is the same stale read from the other direction — and also fires on
every launch before the list loads. An empty Work side is legible on its own;
WorkspaceEmpty says so and offers to create one.

Selection was corrected only at the moment of a switch, but a sandbox joins
the server list only once it has started, so a sandbox created seconds earlier
was never selected: the projects list showed the sandbox while Pinned and
Recents still read the local server. Correcting it from an effect covers the
sandbox arriving late, which is what toggling the tabs by hand was really
doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): return to Code when the last sandbox is deleted

Deleting the only sandbox left the user on a Work side with nothing in it,
still looking at a session belonging to the server that had just gone away.

Driven from the removal itself rather than from watching the sandbox list.
That list updates over IPC a beat after the delete resolves, so a reactive
"no sandboxes means go to Code" rule reads as true both before the list has
loaded and in the moment after the first sandbox is created — which is what
made the earlier attempt at this bounce the user off Work twice over. Asking
"does removing this key empty the side?" against the list as it stands before
the removal has no such window.

It runs beside the tab cleanup, ahead of the awaited platform removal, so the
switch lands immediately rather than after a delete that takes as long as
erasing a disk. Reuses the same enter() as selecting the tab, so Code lands on
its last session rather than wherever the deleted sandbox left the route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quickbeard <quickbeard@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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