poller-lifecycle.test.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. // Regression test for anthropics/claude-code#81571: starting a second
  2. // Claude Code session must not kill the Telegram poller a first session is
  3. // using, and the polling slot must hand off to a surviving server when the
  4. // holder exits.
  5. //
  6. // Run from this directory (deps installed via `bun install`):
  7. // bun test test/poller-lifecycle.test.ts
  8. //
  9. // The pid-slot logic runs before any network call, so a dummy token is
  10. // enough — polling errors from the fake token are expected and irrelevant.
  11. import { afterEach, expect, test } from 'bun:test'
  12. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
  13. import { tmpdir } from 'os'
  14. import { join } from 'path'
  15. const SERVER = join(import.meta.dir, '..', 'server.ts')
  16. type Server = ReturnType<typeof Bun.spawn>
  17. const spawned: Server[] = []
  18. function startServer(stateDir: string): Server {
  19. const proc = Bun.spawn(['bun', SERVER], {
  20. env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
  21. stdin: 'pipe', // held open — the server treats stdin EOF as session exit
  22. stdout: 'ignore',
  23. stderr: 'ignore',
  24. })
  25. spawned.push(proc)
  26. return proc
  27. }
  28. function pidFileContents(stateDir: string): number | null {
  29. try {
  30. return parseInt(readFileSync(join(stateDir, 'bot.pid'), 'utf8'), 10)
  31. } catch {
  32. return null
  33. }
  34. }
  35. async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
  36. const deadline = Date.now() + ms
  37. while (Date.now() < deadline) {
  38. if (cond()) return true
  39. await new Promise(r => setTimeout(r, 100))
  40. }
  41. return cond()
  42. }
  43. function isAlive(pid: number): boolean {
  44. try {
  45. process.kill(pid, 0)
  46. return true
  47. } catch {
  48. return false
  49. }
  50. }
  51. function makeStateDir(): string {
  52. const dir = mkdtempSync(join(tmpdir(), 'tg-poller-test-'))
  53. writeFileSync(join(dir, '.env'), 'TELEGRAM_BOT_TOKEN=123456789:AAHdummy_token_for_pid_logic_only\n')
  54. return dir
  55. }
  56. afterEach(() => {
  57. for (const proc of spawned) {
  58. try {
  59. proc.kill()
  60. } catch {}
  61. }
  62. spawned.length = 0
  63. })
  64. test('second server does not kill the incumbent poller; slot hands off on exit', async () => {
  65. const stateDir = makeStateDir()
  66. try {
  67. // A starts and claims the polling slot.
  68. const a = startServer(stateDir)
  69. expect(await waitFor(() => pidFileContents(stateDir) === a.pid, 10_000)).toBe(true)
  70. // B starts while A is healthy: B must defer, A must survive as holder.
  71. const b = startServer(stateDir)
  72. expect(await waitFor(() => pidFileContents(stateDir) === b.pid, 4_000)).toBe(false)
  73. expect(isAlive(a.pid)).toBe(true)
  74. expect(isAlive(b.pid)).toBe(true)
  75. expect(pidFileContents(stateDir)).toBe(a.pid)
  76. // A's session ends (stdin EOF): B's standby watcher takes over the slot.
  77. a.stdin.end()
  78. await a.exited
  79. expect(await waitFor(() => pidFileContents(stateDir) === b.pid, 10_000)).toBe(true)
  80. expect(isAlive(b.pid)).toBe(true)
  81. // Last session exits: the owner removes its pid file.
  82. b.stdin.end()
  83. await b.exited
  84. expect(await waitFor(() => pidFileContents(stateDir) === null, 5_000)).toBe(true)
  85. } finally {
  86. rmSync(stateDir, { recursive: true, force: true })
  87. }
  88. }, 60_000)
  89. test('a standby exiting leaves the incumbent and its pid file untouched', async () => {
  90. const stateDir = makeStateDir()
  91. try {
  92. const a = startServer(stateDir)
  93. expect(await waitFor(() => pidFileContents(stateDir) === a.pid, 10_000)).toBe(true)
  94. // Short-lived second session: starts, defers, exits.
  95. const b = startServer(stateDir)
  96. await new Promise(r => setTimeout(r, 1_500))
  97. b.stdin.end()
  98. await b.exited
  99. // The incumbent still holds the slot — the exact end state #81571 broke
  100. // (no poller left and no pid file at all).
  101. expect(isAlive(a.pid)).toBe(true)
  102. expect(pidFileContents(stateDir)).toBe(a.pid)
  103. } finally {
  104. rmSync(stateDir, { recursive: true, force: true })
  105. }
  106. }, 60_000)