Compare commits

..
Author SHA1 Message Date
Codeman maintainer 17a976fa2e fix(statusline): unique temp name per exporter refresh, so concurrent creates stay in tmux
ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh
via a temp file + rename whenever the script content changes (a fresh data
dir, or a release that changes it). The temp name was pid + Date.now(), so
claude sessions created in the same millisecond (spawn_workers, a multi-tab
Run) shared one temp path: the first rename consumed it and every other
writer failed with ENOENT on chmod or rename. createSession() treats that as
a mux failure and falls back to a direct PTY, so those sessions silently ran
outside tmux (no reattach after a server restart) while quick-start still
reported success.

Measured on a fresh isolated instance, 4 concurrent claude quick-starts:
master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both
rounds. The temp suffix now comes from randomBytes, like the skill writer in
the same file and user-store.ts already do. The new test freezes Date.now()
and runs eight refreshes at once; it fails on master with the same ENOENT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 21:36:44 +02:00
2 changed files with 27 additions and 2 deletions
+5 -1
View File
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
// windows in which Claude Code could run an empty or non-executable file.
// rename() swaps the complete, already-executable file in atomically.
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
// name let the first rename consume the others' temp file, and their ENOENT
// dropped those sessions from tmux to the direct-PTY fallback.
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmpPath, desired);
await chmod(tmpPath, 0o755);
await rename(tmpPath, scriptPath);
+22 -1
View File
@@ -5,7 +5,7 @@
* hook definitions for desktop notifications.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import {
chmodSync,
closeSync,
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
import {
applyStatusLineConfig,
ensureCodemanHooks,
ensureStatusLineExporterScript,
findEffectiveUserStatusLineCommand,
generateBackgroundWakeScript,
generateHooksConfig,
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
expect(siblings).toEqual([]);
});
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
// A frozen clock makes every writer agree on the timestamp, which is what two
// session creates in one millisecond do. A temp name built from pid + Date.now()
// is then shared: the first rename consumes it, every later rename fails ENOENT,
// and that session's tmux create fell back to a direct PTY.
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
try {
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
expect(new Set(paths)).toEqual(new Set([scriptPath]));
} finally {
clock.mockRestore();
}
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
expect(siblings).toEqual([]);
});
it('never overrides a real, hand-authored statusLine', async () => {
const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true });