Boris Cherny c4ce608cc2
telegram: don't kill a running session's poller when a second session starts
The startup takeover guard SIGTERMed any live bot.pid holder, so starting a
second Claude Code session stole the Telegram polling slot from the session
already using it — and when that second session exited, no poller remained
at all and the bot went permanently silent (anthropics/claude-code#81571).

Replace the kill-on-startup pattern with cooperative slot ownership:
- slot free (no pid file, holder dead, or pid recycled to an unrelated
  process — identity-checked via /proc cmdline or ps args) → claim and poll
- live healthy holder → standby: outbound tools stay fully usable, and a
  watcher claims the slot the moment the holder exits, so the channel hands
  off to the last session standing instead of dying
- nothing is ever killed; genuinely orphaned pollers are already reaped by
  the stdin orphan watchdog, and the pid file is removed by its owner on
  shutdown

Adds a regression test (bun test external_plugins/telegram/test) that spawns
two real servers against an isolated state dir: the second must not kill the
first, the slot must hand off when the holder exits, and the pid file must
be cleaned up by the last server out. Fails on 0.0.7, passes on 0.0.8.

Fixes anthropics/claude-code#81571

No-Verification-Needed: fix is in the plugins repo; verified by its own regression test plus a two-session CLI e2e
2026-08-24 18:06:02 -07:00

123 lines
3.8 KiB
TypeScript

// Regression test for anthropics/claude-code#81571: starting a second
// Claude Code session must not kill the Telegram poller a first session is
// using, and the polling slot must hand off to a surviving server when the
// holder exits.
//
// Run from this directory (deps installed via `bun install`):
// bun test test/poller-lifecycle.test.ts
//
// The pid-slot logic runs before any network call, so a dummy token is
// enough — polling errors from the fake token are expected and irrelevant.
import { afterEach, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const SERVER = join(import.meta.dir, '..', 'server.ts')
type Server = ReturnType<typeof Bun.spawn>
const spawned: Server[] = []
function startServer(stateDir: string): Server {
const proc = Bun.spawn(['bun', SERVER], {
env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
stdin: 'pipe', // held open — the server treats stdin EOF as session exit
stdout: 'ignore',
stderr: 'ignore',
})
spawned.push(proc)
return proc
}
function pidFileContents(stateDir: string): number | null {
try {
return parseInt(readFileSync(join(stateDir, 'bot.pid'), 'utf8'), 10)
} catch {
return null
}
}
async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
const deadline = Date.now() + ms
while (Date.now() < deadline) {
if (cond()) return true
await new Promise(r => setTimeout(r, 100))
}
return cond()
}
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function makeStateDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'tg-poller-test-'))
writeFileSync(join(dir, '.env'), 'TELEGRAM_BOT_TOKEN=123456789:AAHdummy_token_for_pid_logic_only\n')
return dir
}
afterEach(() => {
for (const proc of spawned) {
try {
proc.kill()
} catch {}
}
spawned.length = 0
})
test('second server does not kill the incumbent poller; slot hands off on exit', async () => {
const stateDir = makeStateDir()
try {
// A starts and claims the polling slot.
const a = startServer(stateDir)
expect(await waitFor(() => pidFileContents(stateDir) === a.pid, 10_000)).toBe(true)
// B starts while A is healthy: B must defer, A must survive as holder.
const b = startServer(stateDir)
expect(await waitFor(() => pidFileContents(stateDir) === b.pid, 4_000)).toBe(false)
expect(isAlive(a.pid)).toBe(true)
expect(isAlive(b.pid)).toBe(true)
expect(pidFileContents(stateDir)).toBe(a.pid)
// A's session ends (stdin EOF): B's standby watcher takes over the slot.
a.stdin.end()
await a.exited
expect(await waitFor(() => pidFileContents(stateDir) === b.pid, 10_000)).toBe(true)
expect(isAlive(b.pid)).toBe(true)
// Last session exits: the owner removes its pid file.
b.stdin.end()
await b.exited
expect(await waitFor(() => pidFileContents(stateDir) === null, 5_000)).toBe(true)
} finally {
rmSync(stateDir, { recursive: true, force: true })
}
}, 60_000)
test('a standby exiting leaves the incumbent and its pid file untouched', async () => {
const stateDir = makeStateDir()
try {
const a = startServer(stateDir)
expect(await waitFor(() => pidFileContents(stateDir) === a.pid, 10_000)).toBe(true)
// Short-lived second session: starts, defers, exits.
const b = startServer(stateDir)
await new Promise(r => setTimeout(r, 1_500))
b.stdin.end()
await b.exited
// The incumbent still holds the slot — the exact end state #81571 broke
// (no poller left and no pid file at all).
expect(isAlive(a.pid)).toBe(true)
expect(pidFileContents(stateDir)).toBe(a.pid)
} finally {
rmSync(stateDir, { recursive: true, force: true })
}
}, 60_000)