mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
5c18d06e1e
commit
aa503bca85
6 files changed
+225
-17
No files matched your search
+9
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user