mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix: refuse a case folder move while a session is still being created in it (release review)
The rename lock is checked once, in the Session constructor, but a session
reaches the server's map (where the rename route builds its list of open
sessions) only later, after the create awaits the owner's layout lock and a
fresh settings read (and, for POST /api/sessions, inferLocalCaseName). A
rename in that gap neither listed nor stopped the session, moved the folder,
and the create then registered a session whose pane starts with
`cd "<old path>"`, skipping the 409 requiresStop / stopSessions guarantee.
- case-rename.ts keeps a record of sessions constructed but not yet
registered (`noteCaseSessionStarting` / `caseSessionSettled` /
`isCaseSessionStarting`), with a 30 s TTL for one that never registers
(a failed create, a one-shot prompt), swept on every note and check.
- The Session constructor records itself (local sessions only, as the
route ignores remote ones); registerSessionWithLayout()'s register
callback, the boot-restore registration and stop() settle it.
- The rename route answers 409 CONFLICT with renameCode BUSY ("A session is
starting in this case") for a folder move while one is recorded at or
under the folder. The check runs in the same tick that takes the lock, so
every interleaving is either refused here or by the constructor. An
alias-only rename is not refused.
A create whose path was validated before the move but whose Session is
constructed after it is still the ordinary check-then-use race of any
create path; CLAUDE.md now says so instead of "nothing starts mid-move".
Tests: unit (record, settle, stop, TTL), route (BUSY, then requiresStop once
registered; alias-only unaffected) and an end-to-end WebServer test that
holds the create between constructor and registration (it fails without
the route check: the folder moves under the session).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
40158ce1ef
commit
f8029c2a9e
10 files changed
+238
-7
No files matched your search
@@ -10,6 +10,8 @@
|
||||
*
|
||||
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
|
||||
* folder while it is being moved, and two renames from overlapping;
|
||||
* - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a
|
||||
* rename refuses to move a folder under, since it can neither list nor stop them;
|
||||
* - the move itself, retried a few times for a transient lock and turned into a message that says
|
||||
* what to do when it is a real one;
|
||||
* - carrying Claude Code's history for the folder over to the new path.
|
||||
@@ -54,6 +56,43 @@ export function isCasePathLocked(path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions constructed but not yet in the server's session map, where the rename route looks for the
|
||||
* sessions it must list or stop. A create awaits a few things between `new Session` and that map (the
|
||||
* owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the
|
||||
* session, moved the folder, and left it to start in a path that no longer existed.
|
||||
*/
|
||||
const starting = new Map<object, { path: string; until: number }>();
|
||||
/** A session that never reaches the map (a create that failed, a one-shot prompt) stops counting after this. */
|
||||
const STARTING_TTL_MS = 30000;
|
||||
|
||||
const dropExpiredStarts = (now: number): void => {
|
||||
for (const [session, entry] of starting) {
|
||||
if (entry.until <= now) starting.delete(session);
|
||||
}
|
||||
};
|
||||
|
||||
/** `session` (any object standing for it) is being created with `workingDir`; `Session`'s constructor calls this. */
|
||||
export function noteCaseSessionStarting(session: object, workingDir: string, now = Date.now()): void {
|
||||
dropExpiredStarts(now);
|
||||
starting.set(session, { path: resolve(workingDir), until: now + STARTING_TTL_MS });
|
||||
}
|
||||
|
||||
/** `session` is in the server's session map (or was stopped): the rename route sees it from here on. */
|
||||
export function caseSessionSettled(session: object): void {
|
||||
starting.delete(session);
|
||||
}
|
||||
|
||||
/** True when a session is being created at or inside `path`, so a rename of it must wait. */
|
||||
export function isCaseSessionStarting(path: string, now = Date.now()): boolean {
|
||||
dropExpiredStarts(now);
|
||||
const root = resolve(path);
|
||||
for (const entry of starting.values()) {
|
||||
if (within(entry.path, root)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
|
||||
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
|
||||
|
||||
+7
-1
@@ -151,7 +151,7 @@ import {
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||
import { isCasePathLocked } from './case-rename.js';
|
||||
import { caseSessionSettled, isCasePathLocked, noteCaseSessionStarting } from './case-rename.js';
|
||||
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||
import {
|
||||
launchModelFor,
|
||||
@@ -954,6 +954,10 @@ export class Session extends EventEmitter {
|
||||
if (config.workingDir && isCasePathLocked(config.workingDir)) {
|
||||
throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`);
|
||||
}
|
||||
// And a rename must not start until this session is in the server's map, where it can be listed and
|
||||
// stopped: until registerSessionWithLayout() (or stop()) settles it, the rename route refuses the move.
|
||||
// A remote session's workingDir is the remote path, which the route never counts as the folder's.
|
||||
if (config.workingDir && !config.remote) noteCaseSessionStarting(this, config.workingDir);
|
||||
this.setMaxListeners(25);
|
||||
|
||||
// Default error handler prevents unhandled 'error' events from crashing the process.
|
||||
@@ -5181,6 +5185,8 @@ export class Session extends EventEmitter {
|
||||
async stop(killMux: boolean = true): Promise<void> {
|
||||
// Set stopped flag first to prevent new timers from being created
|
||||
this._isStopped = true;
|
||||
// A session stopped before it was registered (a create that failed) no longer holds up a case rename.
|
||||
caseSessionSettled(this);
|
||||
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
|
||||
// without this the last chip it drew would ride along on its row forever.
|
||||
this._watching = null;
|
||||
|
||||
@@ -55,6 +55,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { prepareNewCasePath } from '../case-path.js';
|
||||
import {
|
||||
CaseRenameError,
|
||||
isCaseSessionStarting,
|
||||
moveCaseFolder,
|
||||
moveClaudeHistory,
|
||||
renameInOrder,
|
||||
@@ -1848,6 +1849,14 @@ export function registerCaseRoutes(
|
||||
if (open.some((s) => !canAccessOwned(user, s.owner))) {
|
||||
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
|
||||
}
|
||||
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
|
||||
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
|
||||
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
|
||||
if (moveFolder && isCaseSessionStarting(oldPath)) {
|
||||
return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', {
|
||||
renameCode: 'BUSY',
|
||||
});
|
||||
}
|
||||
if (open.length > 0 && !body.stopSessions) {
|
||||
return fail(
|
||||
409,
|
||||
|
||||
@@ -242,6 +242,7 @@ import {
|
||||
} from './routes/index.js';
|
||||
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
import { caseSessionSettled } from '../case-rename.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -806,6 +807,8 @@ export class WebServer extends EventEmitter {
|
||||
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
|
||||
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
|
||||
this.sessions.set(session.id, session);
|
||||
// In the map now, so a case rename lists (and on request stops) it instead of refusing the move.
|
||||
caseSessionSettled(session);
|
||||
return () => {
|
||||
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
|
||||
};
|
||||
@@ -3957,6 +3960,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
caseSessionSettled(session);
|
||||
await this.setupSessionListeners(session);
|
||||
|
||||
// Auto-attach PTY to the surviving tmux session immediately.
|
||||
|
||||
Reference in new issue
Block a user