browse: EEXIST on Windows when .gstack exists — mkdirSecure rethrows EEXIST from recursive mkdir (Bun)
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
## Summary
On **Windows (Bun-compiled `browse`)**, every `browse` command aborts with:
```
[browse] EEXIST: file already exists, mkdir 'C:\...\\.gstack'
```
once a `/.gstack/` directory already exists and contains a **stale daemon record** (a `browse.json` / `terminal-*` set whose recorded daemon PID is dead). The daemon-relaunch path then can't recreate the state dir and the whole CLI is dead until the user manually clears `.gstack`.
## Environment
- gstack **v1.58.1.0**
- Windows 11, prebuilt `browse/dist/browse.exe` (Bun-compiled)
- Invoked from inside a git repo that has a pre-existing `/.gstack/`
## Root cause
`mkdirSecure()` in `browse/src/file-permissions.ts`:
```ts
export function mkdirSecure(dirPath: string): void {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
restrictDirectoryPermissions(dirPath);
}
```
On **Bun + Windows**, a *recursive* `mkdirSync` throws `EEXIST` when the directory already exists (Node returns silently — recursive mkdir is supposed to be idempotent). `ensureStateDir()` in `browse/src/config.ts` only special-cases `EACCES`/`ENOTDIR` and **re-throws everything else**, so the `EEXIST` becomes fatal. The function's own docstring even says *"Safe to call on an existing directory"*, so `EEXIST` should be a no-op.
## Reproduce
1. On Windows, run any `browse` command from a repo dir (creates `/.gstack/` + a live daemon record).
2. Let the daemon die uncleanly (machine sleep / crash / kill) so the record goes stale and no live daemon remains.
3. Run any `browse` command again from that dir → `EEXIST: mkdir '...\.gstack'`, repeats on every invocation.
Running from a fresh dir (no `.gstack` yet) works, which is the tell.
## Fix
Treat `EEXIST` as success in `mkdirSecure` (matches its idempotent contract; covers all call sites — `ensureStateDir`, auth dir, skill staging):
```ts
export function mkdirSecure(dirPath: string): void {
try {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
} catch (err: any) {
// Bun on Windows throws EEXIST from a *recursive* mkdir when the dir
// already exists (Node returns silently). The dir existing is the
// desired end state for `mkdir -p`, so treat EEXIST as success.
if (err?.code !== 'EEXIST') throw err;
}
restrictDirectoryPermissions(dirPath);
}
```
## User workaround until a fixed binary ships
```bash
rm -f "/.gstack"/{browse.json,terminal-*,*.log,*.jsonl}
```
Contributor guide
Research direction
Start with browse/src/file-permissions.ts and browse/src/config.ts, especially mkdirSecure() and ensureStateDir(). Reproduce the existing-directory case with Bun on Windows, then verify that an existing .gstack directory no longer aborts browse while unrelated filesystem errors still surface. Confirm the behavior across the mkdirSecure call sites mentioned in the issue.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, typescript
- Domain
- cli, devtools
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100