Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/troubleshooting/WINDOWS_APP_HANG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Windows freezes on startup or window focus

A September 3, 2026 report described the latest version as "lagging" and "freezing all the time." The reported investigation found a 250 MB `sessions.db`, approximately 200 MB of terminal history across 92 stopped sessions, an accidental Git repository at the Windows user profile root, and the isolated PTY host disabled. Windows recorded `AppHangTransient`.

## Prevention

- Startup loads only logs and browser panels that need restart cleanup. Terminal state is restored on demand when accessed, including history for stopped and archived sessions.
- Workspace summaries exclude raw and serialized terminal buffers in SQLite before returning rows to JavaScript. Summary reads do not replace complete cached panel state or write reduced state back to disk.
- Automatic Git status checks and filesystem watching skip the home directory, log a diagnostic, and report unknown Git status. Windows path comparison ignores case and normalizes separators and dot segments. Choose the actual project folder instead of `C:\Users\<user>`. Existing repository records and `.git` directories are not deleted.
- New configurations no longer use the home directory as an implicit Git repository. Legacy home-directory `gitRepoPath` values are rejected by the migration accessor.
- The isolated PTY host defaults to enabled on Windows, including existing configurations without a `usePtyHost` value. Explicit `false` values are preserved. Enable **Settings > Advanced > Use isolated PTY host** and restart Pane to change an existing opt-out. `PANE_USE_PTY_HOST=1` still forces it on for development.

Terminal history remains on disk. The existing 21-day retention policy for archived sessions is unchanged, and no full `VACUUM` runs during startup. A large database file does not by itself mean all history is resident in the JavaScript heap.

## Manual Windows verification

1. Use an isolated `PANE_DIR` with a copy of a database containing many stopped and archived terminal panels. Launch Pane, inspect responsiveness and heap use, then reopen an old terminal and verify its history.
2. In an isolated test profile with a home-directory Git repository, select a session whose worktree path is that home directory. Repeatedly blur/focus Pane. Confirm no recursive watcher or Git status scan starts, and check the diagnostic in the main-process log. Verify a normal project beneath the profile still refreshes.
3. Start with a config missing `usePtyHost`: confirm the settings toggle is enabled and the supervisor forks on Windows. Repeat with explicit `false`, then enable the setting and restart.

The automated tests cover persistence, summary projections, path normalization, Git scan entry points, and platform defaults. They do not reproduce Windows `AppHangTransient` or validate a packaged Windows PTY host.
2 changes: 1 addition & 1 deletion frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface AppConfig {
// Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message)
useInteractiveMode?: boolean;
// Route PTY spawns through an isolated ptyHost UtilityProcess for crash
// isolation. Off by default. Requires app restart; the supervisor is forked
// isolation. On by default on Windows. Requires app restart; the supervisor is forked
// once at `app.whenReady`.
usePtyHost?: boolean;
// PostHog analytics settings
Expand Down
51 changes: 51 additions & 0 deletions main/src/database/database.panel-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { DatabaseService } from './database';

describe('panel history loading', () => {
it('keeps terminal history out of startup and workspace summaries while preserving restoration', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-panel-loading-'));
const db = new DatabaseService(path.join(tempDir, 'sessions.db'));
try {
db.initialize();
const history = 'terminal output\r\n'.repeat(65536);
for (let index = 0; index < 12; index++) {
const id = `session-${index}`;
db.createSession({
id, name: id, initial_prompt: '', worktree_name: id,
worktree_path: tempDir, project_id: null, tool_type: 'none',
});
db.markSessionsAsStopped([id]);
db.createPanel({
id: `panel-${index}`, sessionId: id, type: 'terminal', title: 'Terminal',
state: { isActive: false, customState: {
scrollbackBuffer: history, serializedBuffer: history,
cwd: tempDir, isCliPanel: true, agentType: 'claude',
} },
});
if (index % 2 === 0) db.archiveSession(id);
}
db.createPanel({ id: 'logs', sessionId: 'session-1', type: 'logs', title: 'Logs' });
db.createPanel({ id: 'browser', sessionId: 'session-1', type: 'browser', title: 'Browser' });

expect(db.getPanelsForStartup().map(panel => panel.id).sort()).toEqual(['browser', 'logs']);
for (let index = 0; index < 12; index++) {
const summary = db.getPanelsForSession(`session-${index}`, false)[0];
expect(summary.state.customState).toEqual({ cwd: tempDir, isCliPanel: true, agentType: 'claude' });
expect(db.getPanel(`panel-${index}`)?.state.customState).toMatchObject({ scrollbackBuffer: history });
}
expect(db.getPanelsForSession('session-0')[0].state.customState).toMatchObject({ serializedBuffer: history });
db.createPanel({
id: 'legacy', sessionId: 'session-0', type: 'terminal', title: 'Legacy',
state: JSON.stringify({ isActive: false, customState: { scrollbackBuffer: [history], cwd: tempDir } }),
});
expect(db.getPanelsForSession('session-0', false).find(panel => panel.id === 'legacy')?.state.customState)
.toEqual({ cwd: tempDir });
} finally {
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
17 changes: 13 additions & 4 deletions main/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4592,11 +4592,18 @@ export class DatabaseService {
};
}

getPanelsForSession(sessionId: string): ToolPanel[] {
getPanelsForSession(sessionId: string, includeScrollback = true): ToolPanel[] {
// SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary.
const rows = this.db
.prepare(
"SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at",
includeScrollback
? "SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at"
: `SELECT id, session_id, type, title, metadata, created_at,
json_remove(
CASE WHEN json_type(state) = 'text' THEN json_extract(state, '$') ELSE state END,
'$.customState.scrollbackBuffer', '$.customState.serializedBuffer'
) AS state
FROM tool_panels WHERE session_id = ? ORDER BY created_at`,
)
.all(sessionId) as ToolPanelRow[];

Expand Down Expand Up @@ -4636,10 +4643,12 @@ export class DatabaseService {
});
}

getAllPanels(): ToolPanel[] {
// Only these panel types need restart cleanup. Terminal history stays on disk
// until its session is opened, including stopped and archived sessions.
getPanelsForStartup(): ToolPanel[] {
// SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary.
const rows = this.db
.prepare("SELECT * FROM tool_panels ORDER BY created_at")
.prepare("SELECT * FROM tool_panels WHERE type IN ('logs', 'browser') ORDER BY created_at")
.all() as ToolPanelRow[];

// SAFETY: Panel state and metadata JSON are written by the matching typed panel serializers.
Expand Down
4 changes: 2 additions & 2 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ let paneDaemonHost: PaneDaemonHost | null = null;
let powerSaveManager: PowerSaveManager | null = null;

// ptyHost supervisor — forked as an Electron UtilityProcess on app ready,
// but only when the `usePtyHost` setting is enabled (default: off). When
// but only when the `usePtyHost` setting is enabled (default: on for Windows). When
// disabled, the supervisor is never forked and every manager transparently
// falls through to the legacy in-main `pty.spawn` path.
let ptyHostSupervisor: PtyHostSupervisor | null = null;
Expand Down Expand Up @@ -1165,7 +1165,7 @@ if (launchRemoteSetup) {

// Start the ptyHost supervisor before the window opens so the renderer's
// preload listener for 'ptyHost-port' has a port to receive when the window
// finishes loading. Gated on the `usePtyHost` setting: when off (default),
// finishes loading. Gated on the `usePtyHost` setting: when off,
// the supervisor is never forked and every spawn site falls through to the
// legacy in-main `pty.spawn` path with zero ptyHost code executing.
if (configManager.getUsePtyHost()) {
Expand Down
10 changes: 10 additions & 0 deletions main/src/services/__tests__/gitFileWatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os from 'os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GitFileWatcher } from '../gitFileWatcher';
import type { CommandRunner } from '../../utils/commandRunner';
Expand Down Expand Up @@ -61,6 +62,15 @@ describe('GitFileWatcher pure logic', () => {
internals = watcherInternals(watcher);
}

it('does not start a watcher or invoke Git for the home directory', () => {
const exec = vi.fn(() => '');
build(exec);
watcher.startWatching('home', os.homedir());
expect(internals.watchedSessions.size).toBe(0);
expect(exec).not.toHaveBeenCalled();
expect(logger.warns[0]).toContain('home directory');
});

describe('isIgnoredEventPath', () => {
const none = new Set<string>();

Expand Down
17 changes: 16 additions & 1 deletion main/src/services/__tests__/gitStatusManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import os from 'os';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { GitStatusManager } from '../gitStatusManager';
import type { fastCheckWorkingDirectory as fastCheckWorkingDirectoryImpl, fastGetAheadBehind as fastGetAheadBehindImpl, fastGetDiffStats as fastGetDiffStatsImpl } from '../gitPlumbingCommands';
import type { SessionManager } from '../sessionManager';
import type { WorktreeManager } from '../worktreeManager';
import type { GitDiffManager } from '../gitDiffManager';
import type { Logger } from '../../utils/logger';
import type { GitStatus } from '../../types/session';
import type { GitStatus, Session } from '../../types/session';
import type { GitIndexStatus } from '../gitPlumbingCommands';
import type { CommandRunner } from '../../utils/commandRunner';
import type { DatabaseService } from '../../database/database';
Expand Down Expand Up @@ -146,6 +147,20 @@ describe('GitStatusManager', () => {
});

describe('fetchGitStatus via getGitStatus (cache miss scenarios)', () => {
it('skips home-directory scans on initial load and focus refresh', async () => {
vi.mocked(mockSessionManager.getSession).mockResolvedValue(partialMock<Session>({
...mockSession, worktreePath: os.homedir(),
}));
const initial = await managerPrivates(gitStatusManager).fetchGitStatus('test-session');
expect(initial?.state).toBe('unknown');
managerPrivates(gitStatusManager).updateCache('test-session', { state: 'clean' });
const refreshed = await gitStatusManager.refreshSessionGitStatus('test-session');
expect(refreshed?.state).toBe('unknown');
expect(fastCheckWorkingDirectory).not.toHaveBeenCalled();
expect(mockProjectContext.commandRunner.exec).not.toHaveBeenCalled();
gitStatusManager.stopPolling();
});

it('returns clean state when no changes, no ahead/behind, no untracked', async () => {
vi.mocked(fastCheckWorkingDirectory).mockReturnValue(cleanIndexStatus);
vi.mocked(fastGetAheadBehind).mockReturnValue({ ahead: 0, behind: 0 });
Expand Down
44 changes: 44 additions & 0 deletions main/src/services/configManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,47 @@ describe('ConfigManager appearance persistence', () => {
rename.mockRestore();
});
});


describe('ConfigManager freeze prevention defaults', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!;
let paneDir: string;

beforeEach(async () => {
paneDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pane-freeze-config-'));
vi.stubEnv('PANE_DIR', paneDir);
vi.stubEnv('PANE_USE_PTY_HOST', '');
});

afterEach(async () => {
Object.defineProperty(process, 'platform', originalPlatform);
vi.unstubAllEnvs();
await fs.rm(paneDir, { recursive: true, force: true });
});

it.each(['win32', 'darwin', 'linux'])('defaults the isolated PTY host appropriately on %s', async (platform) => {
Object.defineProperty(process, 'platform', { value: platform });
await fs.writeFile(path.join(paneDir, 'config.json'), JSON.stringify({ verbose: false }));
const manager = new ConfigManager();
await manager.initialize();
expect(manager.getUsePtyHost()).toBe(platform === 'win32');
expect(manager.getConfig().usePtyHost).toBe(platform === 'win32');
expect(manager.getGitRepoPath()).toBe('');
});

it('preserves an explicit Windows opt-out and honors the development override', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await fs.writeFile(path.join(paneDir, 'config.json'), JSON.stringify({ usePtyHost: false }));
const manager = new ConfigManager();
await manager.initialize();
expect(manager.getUsePtyHost()).toBe(false);
vi.stubEnv('PANE_USE_PTY_HOST', '1');
expect(manager.getUsePtyHost()).toBe(true);
});

it('rejects a legacy home-directory Git root but preserves a project path', () => {
expect(new ConfigManager(os.homedir()).getGitRepoPath()).toBe('');
const repoPath = path.join(os.homedir(), 'project');
expect(new ConfigManager(repoPath).getGitRepoPath()).toBe(repoPath);
});
});
14 changes: 10 additions & 4 deletions main/src/services/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktr
import fs from 'fs/promises';
import { watch, type FSWatcher } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety';
import { getAppDirectory } from '../utils/appDirectory';
import { clearShellPathCache } from '../utils/shellPath';
import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder';
Expand Down Expand Up @@ -47,7 +47,8 @@ export class ConfigManager extends EventEmitter {
this.configDir = getAppDirectory();
this.configPath = path.join(this.configDir, 'config.json');
this.config = {
gitRepoPath: defaultGitPath || os.homedir(),
gitRepoPath: defaultGitPath || '',
usePtyHost: process.platform === 'win32',
verbose: false,
anthropicApiKey: undefined,
falApiKey: undefined,
Expand Down Expand Up @@ -411,7 +412,12 @@ export class ConfigManager extends EventEmitter {
}

getGitRepoPath(): string {
return this.config.gitRepoPath || '';
const repoPath = this.config.gitRepoPath || '';
if (isHomeDirectory(repoPath)) {
console.warn(`[ConfigManager] ${HOME_GIT_SCAN_WARNING}`);
return '';
}
return repoPath;
}

isVerbose(): boolean {
Expand All @@ -420,7 +426,7 @@ export class ConfigManager extends EventEmitter {

/**
* Whether PTY spawns should be routed through the isolated ptyHost
* `UtilityProcess`. Off by default. The `PANE_USE_PTY_HOST=1` env var is
* `UtilityProcess`. On by default on Windows. The `PANE_USE_PTY_HOST=1` env var is
* honored as a dev override so testing doesn't require flipping the config.
*/
getUsePtyHost(): boolean {
Expand Down
2 changes: 1 addition & 1 deletion main/src/services/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const databaseService = new DatabaseService(dbPath);
databaseService.initialize();

// Scrollback retention sweep: runs synchronously at module load, which happens
// before panelManager's constructor caches any panels into RAM. Deferring this
// before panelManager restores panels on demand. Deferring this
// (e.g. via a setTimeout in app.whenReady) would let the in-memory panel cache
// keep the stale scrollback for the whole first launch even after the DB is
// trimmed. Result is captured here and logged later once Logger is initialized.
Expand Down
6 changes: 6 additions & 0 deletions main/src/services/gitFileWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety';
import { EventEmitter } from 'events';
import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
import path from 'path';
Expand Down Expand Up @@ -112,6 +113,11 @@ export class GitFileWatcher extends EventEmitter {
// Stop existing watcher if any
this.stopWatching(sessionId);

if (isHomeDirectory(worktreePath)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check the WSL home before starting Git watchers

On Windows with a WSL-backed project, stored worktree paths are POSIX paths such as /home/alice, while isHomeDirectory() defaults to the host's os.homedir() and win32 path handling, so this check compares that path against something like C:\Users\Alice and always returns false. If the WSL user's home is accidentally a repository, execution therefore continues into startWSLNativeWatcher() and its recursive inotifywait or five-second git status fallback; the corresponding status guard fails for the same reason, preserving the freeze this change is intended to prevent. Resolve the distro's home directory when commandRunner.wslContext is present and compare using POSIX semantics.

AGENTS.md reference: AGENTS.md:L47-L47

Useful? React with 👍 / 👎.

this.logger?.warn(`[GitFileWatcher] ${HOME_GIT_SCAN_WARNING}`);
return;
}

try {
if (this.commandRunner?.wslContext) {
if (this.startWSLNativeWatcher(sessionId, worktreePath)) {
Expand Down
12 changes: 10 additions & 2 deletions main/src/services/gitStatusManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety';
import { EventEmitter } from 'events';
import type { Logger } from '../utils/logger';
import type { GitStatus } from '../types/session';
Expand Down Expand Up @@ -332,7 +333,7 @@ export class GitStatusManager extends EventEmitter {
} else {
// Other sessions may now be behind main
const cached = this.cache[session.id];
if (cached && session.worktreePath) {
if (cached && session.worktreePath && !isHomeDirectory(session.worktreePath)) {
try {
// Quick check for new ahead/behind status
const ctx = this.sessionManager.getProjectContext(session.id);
Expand Down Expand Up @@ -382,7 +383,7 @@ export class GitStatusManager extends EventEmitter {
}

const session = await this.sessionManager.getSession(sessionId);
if (!session || !session.worktreePath) {
if (!session || !session.worktreePath || isHomeDirectory(session.worktreePath)) {
return;
}

Expand Down Expand Up @@ -847,6 +848,7 @@ export class GitStatusManager extends EventEmitter {
* Returns true if status is different from cached, false if unchanged
*/
private async hasGitStatusChanged(sessionId: string, worktreePath: string): Promise<boolean> {
if (isHomeDirectory(worktreePath)) return true;
const cached = this.cache[sessionId];
if (!cached) return true;

Expand Down Expand Up @@ -902,6 +904,12 @@ export class GitStatusManager extends EventEmitter {
return null;
}

if (isHomeDirectory(session.worktreePath)) {
this.logger?.warn(`[GitStatus] ${HOME_GIT_SCAN_WARNING}`);
this.abortControllers.delete(sessionId);
return { state: 'unknown', lastChecked: new Date().toISOString() };
}

// Check if operation was cancelled
if (abortController.signal.aborted) {
this.abortControllers.delete(sessionId);
Expand Down
Loading
Loading