mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 08:36:35 +03:00
feat: always-on lifecycle logging for cowork-vm-service (#408)
Previously the daemon was forked with stdio:"ignore" and its internal log() was gated by COWORK_VM_DEBUG=1, so a mid-session crash left no trace anywhere. Issue #408 surfaced this: the daemon died silently after ~40 minutes and the cause was unrecoverable from logs. Changes: - mkdirSync the log directory once at module load so writeLog() isn't silently discarded when the daemon is the first thing writing under ~/.config/Claude/logs/. - Add logLifecycle() — an always-on writer (bypasses DEBUG) for startup, listening, SIGTERM, SIGINT, uncaughtException, unhandledRejection, and process exit. A missing startup entry means fork() didn't complete; a startup with no matching exit means SIGKILL (OOM killer, kill -9, etc). - Hook logLifecycle into the entry point and signal handlers. Works in tandem with Patch 6's stdio redirect: Node-level crash dumps (pre-handler native assertions, etc.) land in the same log file via the fd redirection, so the file becomes the single source of truth for daemon death. Co-Authored-By: Claude <claude@anthropic.com>
This commit is contained in:
@@ -57,6 +57,15 @@ function formatArgs(args) {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Ensure the log directory exists once at startup so writeLog() isn't
|
||||
// silently discarded when the daemon is the first thing writing under
|
||||
// ~/.config/Claude/logs/ (issue #408 — crashes otherwise leave no trace).
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
||||
} catch (_) {
|
||||
// Best-effort — writeLog() still catches any later write errors.
|
||||
}
|
||||
|
||||
function writeLog(level, args) {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = `${ts} [${level}] ${LOG_PREFIX} ${formatArgs(args)}\n`;
|
||||
@@ -67,6 +76,15 @@ function writeLog(level, args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always-on lifecycle logger for startup/shutdown/crash events so the
|
||||
// death of the daemon is never silent regardless of COWORK_VM_DEBUG.
|
||||
function logLifecycle(event, detail) {
|
||||
const stack = detail && detail.stack
|
||||
? detail.stack
|
||||
: (detail !== undefined ? String(detail) : '');
|
||||
writeLog('lifecycle', stack ? [event, stack] : [event]);
|
||||
}
|
||||
|
||||
function log(...args) {
|
||||
if (!DEBUG) return;
|
||||
writeLog('debug', args);
|
||||
@@ -176,24 +194,24 @@ function translateGuestPath(guestPath, mountMap) {
|
||||
log(`translateGuestPath: ${guestPath} -> ${normalized}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a subpath that may be root-relative (e.g. "home/user/.config/...")
|
||||
* or home-relative (e.g. ".config/..."). app.asar generates root-relative
|
||||
* subpaths via path.relative('/', absolutePath), so path.join('/', subpath)
|
||||
* recovers the original absolute path. Falls back to home-relative for
|
||||
* legacy or genuinely relative subpaths.
|
||||
*
|
||||
* Fix for https://github.com/aaddrick/claude-desktop-debian/issues/373
|
||||
*/
|
||||
function resolveSubpath(subpath) {
|
||||
if (!subpath) return os.homedir();
|
||||
const asRoot = path.resolve(path.join('/', subpath));
|
||||
if (asRoot.startsWith(os.homedir() + path.sep) || asRoot === os.homedir()) {
|
||||
return asRoot;
|
||||
}
|
||||
return path.resolve(path.join(os.homedir(), subpath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a subpath that may be root-relative (e.g. "home/user/.config/...")
|
||||
* or home-relative (e.g. ".config/..."). app.asar generates root-relative
|
||||
* subpaths via path.relative('/', absolutePath), so path.join('/', subpath)
|
||||
* recovers the original absolute path. Falls back to home-relative for
|
||||
* legacy or genuinely relative subpaths.
|
||||
*
|
||||
* Fix for https://github.com/aaddrick/claude-desktop-debian/issues/373
|
||||
*/
|
||||
function resolveSubpath(subpath) {
|
||||
if (!subpath) return os.homedir();
|
||||
const asRoot = path.resolve(path.join('/', subpath));
|
||||
if (asRoot.startsWith(os.homedir() + path.sep) || asRoot === os.homedir()) {
|
||||
return asRoot;
|
||||
}
|
||||
return path.resolve(path.join(os.homedir(), subpath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mount-name -> host-path mapping from mountBinds (prior
|
||||
@@ -213,7 +231,7 @@ function buildMountMap(additionalMounts, mountBinds) {
|
||||
const homeDir = os.homedir();
|
||||
for (const [name, info] of Object.entries(additionalMounts)) {
|
||||
if (!info || !info.path) continue;
|
||||
const resolved = resolveSubpath(info.path);
|
||||
const resolved = resolveSubpath(info.path);
|
||||
if (resolved !== homeDir &&
|
||||
!resolved.startsWith(homeDir + path.sep)) {
|
||||
log(`buildMountMap: rejecting "${name}" — resolves outside home: ${resolved}`);
|
||||
@@ -240,31 +258,31 @@ function buildSpawnEnv(appEnv, mountMap) {
|
||||
|
||||
// Translate CLAUDE_CONFIG_DIR from guest path to host path, or
|
||||
// remove it so Claude Code falls back to ~/.claude/.
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR.startsWith('/sessions/')) {
|
||||
// translate guest path to host path
|
||||
const translated = translateGuestPath(
|
||||
mergedEnv.CLAUDE_CONFIG_DIR, mountMap
|
||||
);
|
||||
if (translated !== mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
log(`buildSpawnEnv: translated CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${translated}`);
|
||||
mergedEnv.CLAUDE_CONFIG_DIR = translated;
|
||||
}
|
||||
} else {
|
||||
// Host path — may be doubled by app.asar's own
|
||||
// path.join(homedir, rootRelativeSubpath). Extract the
|
||||
// relative part and resolve it properly.
|
||||
const homeDir = os.homedir();
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR.startsWith(homeDir + path.sep)) {
|
||||
const relative = mergedEnv.CLAUDE_CONFIG_DIR.slice(homeDir.length + 1);
|
||||
const fixed = resolveSubpath(relative);
|
||||
if (fixed !== mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
log(`buildSpawnEnv: fixed doubled CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${fixed}`);
|
||||
mergedEnv.CLAUDE_CONFIG_DIR = fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR.startsWith('/sessions/')) {
|
||||
// translate guest path to host path
|
||||
const translated = translateGuestPath(
|
||||
mergedEnv.CLAUDE_CONFIG_DIR, mountMap
|
||||
);
|
||||
if (translated !== mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
log(`buildSpawnEnv: translated CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${translated}`);
|
||||
mergedEnv.CLAUDE_CONFIG_DIR = translated;
|
||||
}
|
||||
} else {
|
||||
// Host path — may be doubled by app.asar's own
|
||||
// path.join(homedir, rootRelativeSubpath). Extract the
|
||||
// relative part and resolve it properly.
|
||||
const homeDir = os.homedir();
|
||||
if (mergedEnv.CLAUDE_CONFIG_DIR.startsWith(homeDir + path.sep)) {
|
||||
const relative = mergedEnv.CLAUDE_CONFIG_DIR.slice(homeDir.length + 1);
|
||||
const fixed = resolveSubpath(relative);
|
||||
if (fixed !== mergedEnv.CLAUDE_CONFIG_DIR) {
|
||||
log(`buildSpawnEnv: fixed doubled CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${fixed}`);
|
||||
mergedEnv.CLAUDE_CONFIG_DIR = fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Translate CLAUDE_COWORK_MEMORY_PATH_OVERRIDE from guest path to
|
||||
@@ -2366,6 +2384,7 @@ function startServer() {
|
||||
}
|
||||
log(`Listening on ${SOCKET_PATH}`);
|
||||
console.log(`${LOG_PREFIX} Service started on ${SOCKET_PATH}`);
|
||||
logLifecycle('listening', SOCKET_PATH);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
@@ -2378,12 +2397,26 @@ function startServer() {
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', () => {
|
||||
logLifecycle('SIGTERM received');
|
||||
shutdown();
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
logLifecycle('SIGINT received');
|
||||
shutdown();
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
logLifecycle('uncaughtException', err);
|
||||
logError('Uncaught exception:', err);
|
||||
shutdown();
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logLifecycle('unhandledRejection', reason);
|
||||
logError('Unhandled rejection:', reason);
|
||||
});
|
||||
process.on('exit', (code) => {
|
||||
logLifecycle('exit', `code=${code}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2391,10 +2424,11 @@ function startServer() {
|
||||
// ============================================================
|
||||
|
||||
// Always clean up stale socket and start. The app's retry wrapper has a
|
||||
// dedup flag (_svcLaunched) preventing duplicate daemon launches, so a
|
||||
// simple synchronous cleanup avoids the race condition where an async
|
||||
// connection test delays startup while the app is already retrying.
|
||||
// 10s spawn cooldown (_lastSpawn) preventing duplicate daemon launches,
|
||||
// so a simple synchronous cleanup avoids the race condition where an
|
||||
// async connection test delays startup while the app is already retrying.
|
||||
if (require.main === module) {
|
||||
logLifecycle('startup', `pid=${process.pid} sock=${SOCKET_PATH}`);
|
||||
cleanupSocket();
|
||||
startServer();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user