Skip to content

Fix Windows desktop session lease self-lock on model switch / 修复 Windows 桌面端切换模型会话租约自锁#6023

Merged
SivanCola merged 4 commits into
esengine:main-v2from
SivanCola:fix/desktop-lease-key-canonicalization
Jul 5, 2026
Merged

Fix Windows desktop session lease self-lock on model switch / 修复 Windows 桌面端切换模型会话租约自锁#6023
SivanCola merged 4 commits into
esengine:main-v2from
SivanCola:fix/desktop-lease-key-canonicalization

Conversation

@SivanCola

Copy link
Copy Markdown
Collaborator

Summary

Windows desktop users could not switch model / effort / token mode / provider key even in a single window — every attempt failed with:

this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model

This reproduced on fresh installs with the lease info PID pointing at the running process itself (#5999's verification), across v1.16.0–v1.17.0.

Root cause

Two canonical forms for one session path:

  • The lease registry keys sessions through canonicalSessionSavePath — clean + absolute + lowercased on Windows (internal/agent/save.go). SessionLease.Path() returns this folded form.
  • Desktop's sessionRuntimeKey kept the raw path case (desktop/tabs.go).

On Windows every real session path contains uppercase segments (C:\Users\...), so the tab's own lease never compared equal to its session key. ensureSessionLease skipped the reuse fast path, re-acquired against the process's own in-process registry entry (ErrSessionLeaseHeld, with our own PID in the lease info), and the reclaim fallback then hit the tab's own live LockFileEx handle. Single-window self-lock, deterministic.

Two adjacent defects shared the same mismatch or surface:

  • releaseSessionLeaseForKey never matched on Windows, so an abandoned startup build leaked a held lease (session stays busy for every window until restart).
  • A startup build racing a successful model/effort/token switch could later overwrite the freshly installed controller, or release / claim the lease the switch had just bound (the switch paths never superseded in-flight builds; on non-Windows too).

Changes

  • 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 (cloneDetachedRuntimeTab, applyRuntimeTab, detachSessionRuntime) now carry the display path separately from the runtime key; the detached sink rebind hashes the runtime key consistently.
  • Startup build lease hygiene: on ensure failure release only a lease bound to this build's session (never a lease a concurrent switch installed); skip recording acquiredLeaseKey when the fast path merely reused an existing lease.
  • SetModelForTab / SetEffortForTab / SetTokenModeForTab supersede any in-flight startup build (supersedeTabBuildLocked: generation bump + build-context cancel) inside the same critical section that installs the replacement controller.

Tests

  • New: lease/runtime key agreement (cross-platform invariant that fails on Windows before this fix), key idempotence, Windows case-folding (skipped off-Windows), project-scope fallback normalization, fast-path lease reuse without re-acquire.
  • go test ./internal/agent (full, incl. lease suite) — pass.
  • cd desktop && go test . (full suite) — pass; lease/switch subset with -race — pass.
  • GOOS=windows GOARCH=amd64 go build ./... in both modules — clean.

Cache impact

Cache-impact: none. Desktop runtime ownership and lease bookkeeping only; no provider-visible prompts, tool schemas, request serialization, or compaction changes.

Fixes #5999. Fixes #6006. Fixes #5989. Fixes #5990. Refs #5996.

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 esengine#5999. Fixes esengine#6006. Fixes esengine#5989. Fixes esengine#5990. Refs esengine#5996.
@SivanCola SivanCola requested a review from esengine as a code owner July 5, 2026 14:18
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) agent Core agent loop (internal/agent, internal/control) labels Jul 5, 2026
rebuildSettingLocked installed its controller without bumping the tab's
build generation, so a startup build still in flight would later pass
its generation check, overwrite the just-installed controller, and leak
it - leaving the session on a runtime built before the new credentials
were saved. Provider-key saves hit this window most often: users
typically save a key right while the initial startup build is running.

Apply the same supersede-after-successful-swap the model/effort/token
switches use, and add a regression test that models an in-flight build
(live generation plus cancellable build context) and asserts the rebuild
supersedes and cancels it.
merge-order-check added 2 commits July 6, 2026 00:09
…nd session clear

Two more sync-swap paths shared the race fixed for settings rebuilds:

- removeBuiltInProviderAccessAndRetargetTabs and
  deleteProviderAndRetargetTabs cleared tab.Ctrl without bumping the
  build generation, so a startup build planned against the removed
  provider could finish later, pass its generation check, and reinstall
  a controller for it - racing the generation-0 retarget rebuild.
- clearActiveSessionRuntime installed the fresh controller without
  invalidating an in-flight build, whose completion would overwrite the
  swap and resurrect a runtime bound to the just-destroyed session.

Supersede the build inside the same critical sections that perform the
swap, and extract a shared assertion helper for the regression tests
covering all four protected paths.
… the swap

clearActiveSessionRuntime's swap-time identity check silently dropped
the freshly built controller when the tab had been closed or replaced
while the clear ran off-lock: newCtrl was never closed and the session
lease it had just bound stayed held by the abandoned tab struct.

Mirror rebuildSettingLocked's failure branch: close the fresh
controller, release the lease, still finish the destroyed runtime's
teardown, and report the lost swap instead of returning success.
@SivanCola SivanCola merged commit 8ea1c49 into esengine:main-v2 Jul 5, 2026
14 checks passed
SivanCola added a commit that referenced this pull request Jul 5, 2026
…rs, retire lock sidecars

Follow-up polish on the lease/CAS stack after the #6023/#6025 hotfixes,
closing the remaining ways a healthy single-window session could still
present as busy, leak holder details, or litter the session directory.

- Reclaim now arbitrates on the OS lock alone: a missing or unreadable
  lease.json (user cleanup, AV quarantine, crash tear) with a free lock is
  a leftover, not a holder. Previously an orphaned in-process entry whose
  info file disappeared could never be reclaimed - the fallback acquire
  re-hit the orphaned entry forever and every rebuild stayed busy. The
  desktop gate mirrors this: nil lease info allows the reclaim attempt,
  foreign holders are still refused.
- Raw lease errors (session path + host-pid-writer id) can no longer reach
  the UI: startup bind failures (StartupErr feeds the topbar banner and tab
  metas), ClearSession's bridge returns, and the recovery-lease callback
  (chat notices via snapshot errors) all render the user-facing busy
  message; raw details stay in slog. The busy message drops its dangling
  'before changing this setting' clause when no setting applies.
- SessionLease.Release retires the .lock/.lease.lock sidecars through the
  existing atomic take-then-remove helpers (non-blocking, no-op against any
  live holder or in-flight save), so ordinary use stops accumulating lock
  files that previously waited for the next boot reconcile (#6014).
- Autosave failures warn once per burst and at most once per 5 minutes per
  tab: retries are slog-only, the user notice fires when the burst gives
  up. Explicit action saves keep immediate feedback. Every failure is still
  logged.
- SaveRecoveryBranch tolerates event-index write failures like the other
  save paths: the recovery transcript and meta are already durable, so a
  failed listing accelerator no longer discards a successful recovery.
- Lease test suite now feeds user-shape (mixed-case) paths to the APIs
  under test instead of pre-canonicalized ones - the pattern that let the
  Windows case-fold mismatch (#5999) escape the suite - plus regressions
  for orphan-without-info, corrupt-info, foreign-info-refused, sidecar
  retirement on release, StartupErr sanitization, and the autosave warning
  debounce.

Refs #6014 #5999 #6027.
SivanCola pushed a commit to SivanCola/DeepSeek-Reasonix that referenced this pull request Jul 6, 2026
Same-shape sweep after the initial guard landed:

- Case-variant bypass (confirmed by repro): EvalSymlinks does not
  normalize case, and default macOS/Windows volumes are
  case-insensitive, so ~/.reasonix/SESSIONS/x.jsonl reached the real
  store while sliding past the deny check — the same failure shape as
  the Windows lease-key case split (esengine#6023). Deny-side checks (the
  guard and forbid_read's confineRead) now fold case on those
  platforms; allow-side confine keeps exact matching, since folding an
  allow rule on a case-sensitive filesystem would wave a genuinely
  different directory through.
- State-root-direct runtime ledgers (desktop-tabs.json and friends,
  heartbeat-tasks.json, metrics/crash-pending.json) are rewritten
  wholesale by the app, so agent edits vanish the same way session
  edits do — the "index files" from the user report. They join the
  guard; config.toml/credentials/skills stay writable on request.
- reasonix review built its subagent bash from the init template,
  skipping the guard; it now carries the same session-data warning.
SivanCola pushed a commit to SivanCola/DeepSeek-Reasonix that referenced this pull request Jul 6, 2026
Same-shape sweep after the initial guard landed:

- Case-variant bypass (confirmed by repro): EvalSymlinks does not
  normalize case, and default macOS/Windows volumes are
  case-insensitive, so ~/.reasonix/SESSIONS/x.jsonl reached the real
  store while sliding past the deny check — the same failure shape as
  the Windows lease-key case split (esengine#6023). Deny-side checks (the
  guard and forbid_read's confineRead) now fold case on those
  platforms; allow-side confine keeps exact matching, since folding an
  allow rule on a case-sensitive filesystem would wave a genuinely
  different directory through.
- State-root-direct runtime ledgers (desktop-tabs.json and friends,
  heartbeat-tasks.json, metrics/crash-pending.json) are rewritten
  wholesale by the app, so agent edits vanish the same way session
  edits do — the "index files" from the user report. They join the
  guard; config.toml/credentials/skills stay writable on request.
- reasonix review built its subagent bash from the init template,
  skipping the guard; it now carries the same session-data warning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

1 participant