mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-09-08 17:51:46 -03:00
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
123 lines
3.8 KiB
TypeScript
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)
|