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:
Travis Stockton
2026-04-16 12:06:18 -05:00
parent cb0d636f20
commit a349dee057

View File

@@ -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();
}