fix(daemon): stop the worker holding the user's project as its cwd (#3706) (#3727)

* fix(daemon): stop the worker holding the user's project as its cwd

A process keeps an open handle on its working directory. The daemon spawns
are reached from hooks, which run in the session's project folder, and none of
the three spawn sites set a directory -- so the daemon inherited it and, on
Windows, held it for the daemon's whole life. Since the daemon outlives the
session, renaming or moving the project failed with "The process cannot access
the file because it is being used by another process" until the user found and
killed bun.exe. POSIX allows the rename but still pins the directory against
unmount.

All three now stand in claude-mem's own data directory: the Windows
Start-Process command gains -WorkingDirectory, and the two detached spawns
gain cwd. The data directory always exists by the time a daemon starts and is
never a directory the user is reorganising.

Closes #3706

* fix(daemon): create the working directory before handing it to spawn

Passing a cwd that does not exist is worse than passing none: spawn fails
with ENOENT and Start-Process refuses outright, so on a fresh install this
fix would have turned the first daemon launch into a failure.

paths.ts resolves DATA_DIR but does not create it -- ensureDir is a helper
callers must call. Today some earlier caller happens to create it before any
daemon spawns, which is why this did not show up in testing, but the spawn
sites must not depend on that ordering. All three now mkdir -p first, which
is idempotent.
This commit is contained in:
Nguyen Thanh Dat
2026-09-11 14:12:02 +07:00
committed by GitHub
parent dc36ee7d45
commit fa0f4e7fe6
4 changed files with 92 additions and 5 deletions
+7
View File
@@ -858,10 +858,17 @@ function getServerPort(): number {
function spawnServerDaemon(port: number): number | undefined {
const scriptPath = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
// A cwd that does not exist makes spawn fail with ENOENT, and paths.ts resolves
// DATA_DIR without creating it — so create it rather than depend on some earlier
// caller having done so. Idempotent.
mkdirSync(paths.dataDir(), { recursive: true });
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
// Never the caller's directory: a daemon holds its cwd open for its whole life, and
// on Windows that locks the folder against rename or move (#3706).
cwd: paths.dataDir(),
// Strip host CLI bleed-through (CLAUDE_CODE_*, including EFFORT_LEVEL) and
// Anthropic credentials before handing env to the detached daemon. The
// daemon re-reads credentials from ~/.claude-mem/.env at SDK spawn time.
+29 -2
View File
@@ -357,7 +357,33 @@ function executeCwdRemap(dbPath: string, effectiveDataDir: string, markerPath: s
}
}
export function buildWindowsDaemonStartCommand(runtimePath: string, scriptPath: string): string {
/**
* Where a detached daemon should stand, which is anywhere but the user's project.
*
* A process holds an open handle on its working directory. On Windows that makes the
* directory unrenamable and unmovable for the daemon's whole lifetime, and the daemon
* outlives the session that spawned it -- so a project folder became permanently locked
* with "The process cannot access the file because it is being used by another process"
* until the user found and killed bun.exe (#3706). POSIX allows the rename but still
* pins the directory against unmount. claude-mem's own data directory always exists by
* the time a daemon starts and is never a directory the user is reorganising.
*/
export function daemonWorkingDirectory(): string {
const dir = paths.dataDir();
// Created here rather than assumed: a cwd that does not exist makes spawn fail with
// ENOENT and Start-Process fail outright, so passing one turns a first run on a fresh
// install into a launch failure. paths.ts resolves DATA_DIR but does not create it --
// today something else happens to create it first, which is a coupling this must not
// depend on. mkdir -p is idempotent, so the usual case costs one stat.
mkdirSync(dir, { recursive: true });
return dir;
}
export function buildWindowsDaemonStartCommand(
runtimePath: string,
scriptPath: string,
workingDirectory: string = daemonWorkingDirectory()
): string {
const psSingleQuote = (value: string) => value.replace(/'/g, "''");
// Windows PowerShell 5.1 joins -ArgumentList elements with spaces WITHOUT
// quoting them when it builds the child's native command line, so a script
@@ -366,7 +392,7 @@ export function buildWindowsDaemonStartCommand(runtimePath: string, scriptPath:
// double quotes inside the single-quoted PS string keeps the path a single
// argument. -FilePath is safe as-is: it is a single-string parameter and
// never goes through that join.
return `Start-Process -FilePath '${psSingleQuote(runtimePath)}' -ArgumentList @('"${psSingleQuote(scriptPath)}"','--daemon') -WindowStyle Hidden`;
return `Start-Process -FilePath '${psSingleQuote(runtimePath)}' -ArgumentList @('"${psSingleQuote(scriptPath)}"','--daemon') -WorkingDirectory '${psSingleQuote(workingDirectory)}' -WindowStyle Hidden`;
}
export const WORKER_BOOT_PROBE_TIMEOUT_MS = 5000;
@@ -520,6 +546,7 @@ export function spawnDaemon(
const child = spawnHidden(execPath, args, {
detached: true,
stdio: 'ignore',
cwd: daemonWorkingDirectory(),
env
});
+7
View File
@@ -687,9 +687,16 @@ export async function ensureWorkerRunning(): Promise<boolean> {
logger.info('SYSTEM', 'Worker not running — lazy-spawning', { runtimePath, scriptPath });
try {
// A cwd that does not exist makes spawn fail with ENOENT, and paths.ts resolves
// DATA_DIR without creating it. Idempotent, so the usual case costs one stat.
mkdirSync(DATA_DIR, { recursive: true });
const proc = spawnHidden(runtimePath, [scriptPath, '--daemon'], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
// This spawn runs from a hook, so the inherited cwd is the user's project. A
// daemon holds its cwd open for its whole life, and on Windows that locks the
// folder against rename or move long after the session ends (#3706).
cwd: DATA_DIR,
});
proc.unref();
} catch (error: unknown) {
+49 -3
View File
@@ -29,6 +29,7 @@ const {
probeWorkerBootFailure,
shouldRetryWorkerBootProbe,
buildWindowsDaemonStartCommand,
daemonWorkingDirectory,
resolveWorkerRuntimePath,
captureProcessStartToken,
verifyPidFileOwnership,
@@ -688,10 +689,10 @@ describe('ProcessManager', () => {
const runtimePath = String.raw`C:\Users\Test User\.bun\bin\bun.exe`;
const scriptPath = String.raw`C:\Users\Test User\.claude\plugins\marketplaces\thedotmack\plugin\scripts\worker-service.cjs`;
const command = buildWindowsDaemonStartCommand(runtimePath, scriptPath);
const command = buildWindowsDaemonStartCommand(runtimePath, scriptPath, String.raw`C:\daemon-home`);
expect(command).toBe(
`Start-Process -FilePath '${runtimePath}' -ArgumentList @('"${scriptPath}"','--daemon') -WindowStyle Hidden`
`Start-Process -FilePath '${runtimePath}' -ArgumentList @('"${scriptPath}"','--daemon') -WorkingDirectory 'C:\\daemon-home' -WindowStyle Hidden`
);
});
@@ -711,7 +712,7 @@ describe('ProcessManager', () => {
);
expect(command).toBe(
`Start-Process -FilePath 'C:\\Users\\O''Brien\\.bun\\bin\\bun.exe' -ArgumentList @('"C:\\Users\\O''Brien\\plugin\\scripts\\worker-service.cjs"','--daemon') -WindowStyle Hidden`
`Start-Process -FilePath 'C:\\Users\\O''Brien\\.bun\\bin\\bun.exe' -ArgumentList @('"C:\\Users\\O''Brien\\plugin\\scripts\\worker-service.cjs"','--daemon') -WorkingDirectory '${DATA_DIR.replace(/'/g, "''")}' -WindowStyle Hidden`
);
});
});
@@ -803,6 +804,51 @@ describe('ProcessManager', () => {
});
});
// A process holds an open handle on its working directory. On Windows that locks the
// directory against rename and move for as long as the process lives, and a daemon
// outlives the session that spawned it -- so a hook-spawned daemon inheriting the
// project folder left it permanently locked (#3706).
describe('daemon working directory (#3706)', () => {
it('pins the daemon to a directory the user is not working in', () => {
const command = buildWindowsDaemonStartCommand(
String.raw`C:\bun\bun.exe`,
String.raw`C:\plugin\worker-service.cjs`
);
expect(command).toContain('-WorkingDirectory');
expect(command).toContain(`-WorkingDirectory '${DATA_DIR.replace(/'/g, "''")}'`);
});
it('escapes a single quote in the working directory', () => {
const command = buildWindowsDaemonStartCommand(
String.raw`C:\bun\bun.exe`,
String.raw`C:\plugin\worker-service.cjs`,
String.raw`C:\Users\O'Brien\.claude-mem`
);
expect(command).toContain(String.raw`-WorkingDirectory 'C:\Users\O''Brien\.claude-mem'`);
});
it('defaults to the claude-mem data directory, never the caller cwd', () => {
expect(daemonWorkingDirectory()).toBe(DATA_DIR);
expect(daemonWorkingDirectory()).not.toBe(process.cwd());
});
// Passing a cwd that does not exist is worse than passing none: spawn fails with
// ENOENT and Start-Process refuses outright, so this fix would turn a first run on
// a fresh install into a launch failure. paths.ts resolves DATA_DIR but never
// creates it — today some earlier caller happens to, which is not a guarantee.
it('creates the directory it hands out, so a fresh install can spawn', () => {
rmSync(DATA_DIR, { recursive: true, force: true });
expect(existsSync(DATA_DIR)).toBe(false);
const dir = daemonWorkingDirectory();
expect(existsSync(dir)).toBe(true);
expect(statSync(dir).isDirectory()).toBe(true);
});
});
describe('SIGHUP handling', () => {
it('should have SIGHUP listeners registered (integration check)', () => {
if (process.platform === 'win32') return;