fix(desktop): unify session runtime keys with lease canonicalization

The lease registry keys sessions through agent's canonical form (clean,
absolute, lowercased on Windows), while desktop sessionRuntimeKey kept the
raw path case. On Windows every tab path contains uppercase segments
(C:\Users\...), so the tab's own lease never matched its session key:
ensureSessionLease re-acquired against the process's own registry entry,
reclaim then hit the tab's live OS lock handle, and every model / effort /
token-mode / provider-key rebuild failed with the "already open in another
Reasonix window" error on a single window — including fresh installs.

- Export agent.CanonicalSessionPath and layer it into sessionRuntimeKey so
  desktop identity checks agree with the lease registry byte-for-byte.
- Normalize the canonicalTabSessionPath fallback (project-scope sessions)
  with clean+abs so shape variants cannot split one file into two keys.
- Keep case-folded keys out of SessionPath: detach/attach paths now carry
  the display path separately from the runtime key.
- Release only the build's own lease on startup-build failure, and skip
  claiming a lease the build merely reused (a concurrent switch owns it).
- Supersede in-flight startup builds when a model/effort/token switch
  installs its controller, so a slow blank-session build can no longer
  overwrite the new runtime or strip its lease.
- Regression tests: lease/runtime key agreement, idempotence, Windows case
  folding, project-scope fallback normalization, fast-path lease reuse.

Fixes #5999. Fixes #6006. Fixes #5989. Fixes #5990. Refs #5996.
This commit is contained in:
SivanCola committed 2026-07-05 22:17:38 +08:00
1 parent 5c18d06e1e
commit aa503bca85
6 files changed
+225 -17

No files matched your search

+9 -1
View File
@@ -1582,7 +1582,10 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
snap := a.tabRuntimeSnapshot(tab)
oldSink := snap.sink
if oldSink != nil {
oldSink.setBinding(detachedRuntimeTabID(oldPath), nil)
// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
// derives — a raw path here would hash to a different detached id on
// Windows where keys are case-folded.
oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
oldSink.clearContext()
}
if oldCtrl.RuntimeStatus().Cancellable {
@@ -7155,6 +7158,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
tab.model = name
tab.effort = cloneStringPtr(effortOverride)
tab.Label = newCtrl.Label()
// Supersede any in-flight startup build: it would otherwise finish later,
// overwrite this controller, and release/steal the tab's session lease.
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
@@ -7304,6 +7310,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
tab.Label = newCtrl.Label()
tab.StartupErr = ""
tab.Ready = true
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
@@ -7429,6 +7436,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
tab.Label = newCtrl.Label()
tab.StartupErr = ""
tab.Ready = true
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/agent"
)
// The lease registry folds session paths through agent.CanonicalSessionPath
// (lowercased on Windows). Every desktop "same session" comparison must fold
// the same way, or a lease the tab itself holds looks foreign and every
// model/effort/token switch self-locks with the "already open in another
// window" error (#5999, #6006, #5996).
func TestSessionRuntimeKeyMatchesLeaseKey(t *testing.T) {
dir := t.TempDir()
// Mixed-case segment on every platform; on Windows the lease key folds it.
path := filepath.Join(dir, "Sessions-Dir", "20260705-Test.jsonl")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("acquire lease: %v", err)
}
defer lease.Release()
if got, want := sessionRuntimeKey(lease.Path()), sessionRuntimeKey(path); got != want {
t.Fatalf("lease path key %q != session path key %q; lease reuse checks will self-lock", got, want)
}
}
func TestSessionRuntimeKeyIsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Case-Mix", "20260705-Test.jsonl")
key := sessionRuntimeKey(path)
if key == "" {
t.Fatal("empty key for non-empty path")
}
if again := sessionRuntimeKey(key); again != key {
t.Fatalf("sessionRuntimeKey not idempotent: %q -> %q", key, again)
}
}
func TestSessionRuntimeKeyFoldsCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive path identity is Windows-only")
}
dir := t.TempDir()
path := filepath.Join(dir, "Sessions", "20260705-Test.jsonl")
upper := strings.ToUpper(path)
if sessionRuntimeKey(path) != sessionRuntimeKey(upper) {
t.Fatalf("case variants of one file produced distinct keys: %q vs %q",
sessionRuntimeKey(path), sessionRuntimeKey(upper))
}
}
func TestCanonicalTabSessionPathNormalizesOutsideSessionDir(t *testing.T) {
// Project-scope sessions live outside config.SessionDir(); the fallback
// must still clean the shape so one file cannot split into two keys.
dir := t.TempDir()
base := filepath.Join(dir, "projects", "p1", "sessions", "20260705-a.jsonl")
messy := filepath.Join(dir, "projects", "p1", ".", "sessions") + string(filepath.Separator) + "20260705-a.jsonl"
if sessionRuntimeKey(base) != sessionRuntimeKey(messy) {
t.Fatalf("path shape variants split keys: %q vs %q",
sessionRuntimeKey(base), sessionRuntimeKey(messy))
}
}
func TestEnsureSessionLeaseReusesHeldLease(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Sessions", "20260705-Reuse.jsonl")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
tab := &WorkspaceTab{ID: "tab_reuse"}
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("first ensure: %v", err)
}
defer tab.releaseSessionLease()
acquires := 0
sessionLeaseAcquireHookForTest = func() { acquires++ }
defer func() { sessionLeaseAcquireHookForTest = nil }()
// Same path again — and the lease's own (canonical) form: both must hit
// the fast path instead of re-acquiring against our own registry entry.
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("re-ensure same path: %v", err)
}
if err := tab.ensureSessionLease(tab.sessionLeaseRuntimeKey()); err != nil {
t.Fatalf("re-ensure canonical form: %v", err)
}
if acquires != 0 {
t.Fatalf("expected fast-path reuse, got %d new acquires", acquires)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ func sharedHostRefsForTest(t *testing.T, app *App, root string) (int, bool) {
func TestCloneDetachedRuntimeTabPreservesSharedHostKey(t *testing.T) {
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "workspace-root"}
detached := cloneDetachedRuntimeTab(tab, "session-key")
detached := cloneDetachedRuntimeTab(tab, "session-key", "session-key")
if detached == nil {
t.Fatal("cloneDetachedRuntimeTab returned nil")
}
+59 -15
View File
@@ -241,8 +241,14 @@ func (t *WorkspaceTab) hasActiveRuntimeWork() bool {
return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
}
// sessionRuntimeKey is the comparison/map key for "same session" checks. It
// layers agent.CanonicalSessionPath on top of the desktop path normalization
// so the key matches the form held by session leases (lowercased on Windows).
// Comparing a lease's Path() against a raw tab path without this fold made
// every rebuild on Windows look like a foreign holder (self-lock, #5999).
// Keys are identities only — never use them as display or file paths.
func sessionRuntimeKey(path string) string {
return canonicalTabSessionPath(path)
return agent.CanonicalSessionPath(canonicalTabSessionPath(path))
}
var sessionLeaseAcquireHookForTest func()
@@ -426,7 +432,7 @@ func (a *App) detachSessionRuntime(tab *WorkspaceTab) bool {
}
a.mu.Lock()
a.ensureDetachedSessionsLocked()
tab.SessionPath = key
tab.SessionPath = canonicalTabSessionPath(path)
a.detachedSessions[key] = tab
a.mu.Unlock()
return true
@@ -437,8 +443,10 @@ func (a *App) detachSessionRuntime(tab *WorkspaceTab) bool {
// ActivityStatus, disabledMCP, ...) are written under a.mu by bound methods
// and the event sink, and the disabledMCP map read would otherwise race those
// writers. The session lease is transferred separately by the caller through
// the sessionLeaseMu helpers.
func cloneDetachedRuntimeTab(tab *WorkspaceTab, key string) *WorkspaceTab {
// the sessionLeaseMu helpers. key is the runtime identity (map key / tab id
// hash); path is the real session path — keys are case-folded on Windows and
// must not leak into SessionPath, which is displayed and persisted.
func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab {
if tab == nil {
return nil
}
@@ -454,7 +462,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key string) *WorkspaceTab {
SharedHostKey: tab.SharedHostKey,
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
SessionPath: key,
SessionPath: canonicalTabSessionPath(path),
Ctrl: tab.Ctrl,
Label: tab.Label,
Ready: tab.Ready,
@@ -494,12 +502,13 @@ func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
a.mu.Unlock()
return false
}
key := sessionRuntimeKey(tab.currentSessionPath())
sourcePath := tab.currentSessionPath()
key := sessionRuntimeKey(sourcePath)
if key == "" {
a.mu.Unlock()
return false
}
detached := cloneDetachedRuntimeTab(tab, key)
detached := cloneDetachedRuntimeTab(tab, key, sourcePath)
if detached == nil {
a.mu.Unlock()
return false
@@ -522,7 +531,10 @@ func (a *App) detachRuntimeForReplacement(tab *WorkspaceTab) bool {
return true
}
func applyRuntimeTab(target, source *WorkspaceTab, key string, wailsCtx context.Context, app *App) {
// applyRuntimeTab moves source's runtime (controller, sink, lease, telemetry)
// onto target. path is the real session path for display/persistence; the
// case-folded runtime key must never be written into SessionPath.
func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context.Context, app *App) {
if target == nil || source == nil {
return
}
@@ -539,7 +551,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, key string, wailsCtx context.
target.Ctrl = source.Ctrl
target.sink = source.sink
target.adoptSessionLease(source.takeSessionLease())
target.SessionPath = key
target.SessionPath = canonicalTabSessionPath(path)
target.SharedHostKey = source.SharedHostKey
target.Label = source.Label
target.Ready = true
@@ -571,7 +583,7 @@ func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, wails
detached := a.detachedSessions[key]
if detached != nil {
delete(a.detachedSessions, key)
applyRuntimeTab(tab, detached, key, wailsCtx, a)
applyRuntimeTab(tab, detached, path, wailsCtx, a)
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
@@ -602,7 +614,7 @@ func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, wails
if a.activeTabID == source.ID {
a.activeTabID = tab.ID
}
applyRuntimeTab(tab, source, key, wailsCtx, a)
applyRuntimeTab(tab, source, path, wailsCtx, a)
a.saveTabsLocked()
attachedCtrl := tab.Ctrl
a.mu.Unlock()
@@ -2389,6 +2401,22 @@ func (a *App) tabBuildSuperseded(tab *WorkspaceTab, generation uint64) bool {
return a.tabBuildSupersededLocked(tab, generation)
}
// supersedeTabBuildLocked invalidates any in-flight startup build and cancels
// its context. A synchronous rebuild (model/effort/token switch) that has
// already installed its controller calls this so a slower blank-session build
// cannot finish afterward, overwrite tab.Ctrl, and release or steal the
// session lease the switch just bound. Callers must hold a.mu.
func (a *App) supersedeTabBuildLocked(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.buildGeneration++
if tab.buildCancel != nil {
tab.buildCancel()
tab.buildCancel = nil
}
}
// abandonSupersededBuild cleans up after a build that lost tab ownership
// mid-flight (removed tab, or a session rebind bumped the generation). It
// releases only what THIS build acquired — its controller, its own
@@ -2752,6 +2780,7 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
a.emitReady(wailsCtx, tab.ID)
return
}
preLeaseKey := tab.sessionLeaseRuntimeKey()
if err := tab.ensureSessionLease(path); err != nil {
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
@@ -2762,7 +2791,10 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
tab.StartupErr = err.Error()
tab.Ready = true
hostKey := takeTabSharedHostKey(tab)
tab.releaseSessionLease()
// Release only a lease bound to THIS build's session: a failed
// ensure leaves any prior lease untouched, and that lease may
// belong to a runtime a concurrent switch just installed.
tab.releaseSessionLeaseForKey(sessionRuntimeKey(path))
a.mu.Unlock()
ctrl.Close()
if hostKey != "" {
@@ -2773,8 +2805,12 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
}
// Remember which lease THIS build bound: if the build is later
// superseded, only a lease still carrying this key may be
// released (see abandonSupersededBuild).
acquiredLeaseKey = sessionRuntimeKey(path)
// released (see abandonSupersededBuild). A fast-path reuse means
// the lease existed before this build (bound by a concurrent
// switch or recovery) — it is not ours to release.
if key := sessionRuntimeKey(path); key != preLeaseKey {
acquiredLeaseKey = key
}
// Re-check ownership right after the (potentially slow) lease
// bind: a rebind that superseded this build while ensure was in
// flight has already retargeted the tab, and continuing into
@@ -7017,7 +7053,15 @@ func canonicalTabSessionPath(path string) string {
if validPath, _, err := validateSessionPath(config.SessionDir(), path); err == nil {
return validPath
}
return path
// Project-scope sessions live outside config.SessionDir(), so validation
// against it always fails. Still normalize the shape: without clean+abs,
// the same file spelled with a different separator or a relative prefix
// splits into distinct runtime keys.
cleaned := filepath.Clean(path)
if abs, err := filepath.Abs(cleaned); err == nil {
return abs
}
return cleaned
}
func (a *App) rememberTabSessionPath(tab *WorkspaceTab, path string) {
+39
View File
@@ -0,0 +1,39 @@
package agent
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCanonicalSessionPathMatchesLeaseRegistryKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "Mixed-Case", "20260705-Test.jsonl")
if got, want := CanonicalSessionPath(path), canonicalSessionSavePath(path); got != want {
t.Fatalf("CanonicalSessionPath(%q) = %q, want lease key %q", path, got, want)
}
}
func TestCanonicalSessionPathIdempotentAndEmptySafe(t *testing.T) {
if got := CanonicalSessionPath(""); got != "" {
t.Fatalf("empty path resolved to %q; must stay empty", got)
}
if got := CanonicalSessionPath(" "); got != "" {
t.Fatalf("blank path resolved to %q; must stay empty", got)
}
path := filepath.Join(t.TempDir(), "A", "b.jsonl")
key := CanonicalSessionPath(path)
if again := CanonicalSessionPath(key); again != key {
t.Fatalf("not idempotent: %q -> %q", key, again)
}
}
func TestCanonicalSessionPathFoldsCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case folding is Windows-only")
}
path := filepath.Join(t.TempDir(), "Sessions", "20260705-Test.jsonl")
if CanonicalSessionPath(path) != CanonicalSessionPath(strings.ToUpper(path)) {
t.Fatal("case variants of one file produced distinct keys")
}
}
+14
View File
@@ -813,6 +813,20 @@ func canonicalSessionSavePath(path string) string {
return key
}
// CanonicalSessionPath is the identity key of a session path: cleaned,
// absolute, and case-folded on Windows, matching the key form used by the
// lease registry and the save-path locks. Any runtime bookkeeping that
// compares or maps session paths (desktop tabs, detached runtimes) must use
// this exact form, or the same file splits into distinct keys — e.g.
// `C:\Users\...` vs the lease's lowercased `c:\users\...`. Empty input stays
// empty instead of resolving to the working directory.
func CanonicalSessionPath(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
return canonicalSessionSavePath(path)
}
// LoadSession reads a saved session into a fresh Session value. New sessions
// replay the append-only event log; legacy sessions without an event log fall
// back to the compatibility .jsonl checkpoint. A damaged log is replayed to its