Windows: telegram plugin's stale-poller eviction never fires — `ps -p <pid> -o args=` is unsupported by Cygwin ps, failure swallowed, bot.pid overwritten anyway

Status Open
Reported on v2.1.229
Maintainer reply None cached
Activity 0 comments · opened Aug 20, 2026

Summary

On Windows the telegram channel plugin's stale-poller eviction never fires. The guard shells out to ps -p <pid> -o args= to confirm the PID-file holder is really a server.ts process before sending SIGTERM. On Windows the ps found on PATH is Cygwin/MSYS ps (shipped with Git for Windows), which does not implement -o:

$ ps -p 29736 -o args=
ps: unknown option -- o
Try `ps --help' for more information.
exit code: 1

execFileSync throws on the non-zero exit, the surrounding try { … } catch {} swallows it, and the SIGTERM is never sent. writeFileSync(PID_FILE, …) on the next line still runs unconditionally, so bot.pid now advertises the new process while the old poller keeps holding Telegram's single getUpdates slot.

Net effect: on Windows the only defense against two concurrent pollers is dead by construction — not intermittently, not as a race, but on every single start. Every additional Claude Code instance adds one more permanently-conflicting poller, and bot.pid becomes actively misleading for anyone debugging it.

Environment

| | |
|---|---|
| OS | Windows 11 Pro 10.0.26200 |
| Claude Code | 2.1.229 (CLI) and 2.1.234 (Claude Desktop), same CLAUDE_CONFIG_DIR |
| Plugin | telegram@claude-plugins-official 0.0.7 |
| Runtime | bun 1.3.14 |
| ps on PATH | C:\Program Files\Git\usr\bin\ps.exe — ps (cygwin) 3.4.10 |
| Plugin enablement | enabledPlugins in the global settings.json |

Affected code

~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.7/server.ts:62-78

try {
  const stale = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
  if (stale > 1 && stale !== process.pid) {
    process.kill(stale, 0)
    const cmd = execFileSync('ps', ['-p', String(stale), '-o', 'args='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })  // line 71 — throws on Windows
    if (cmd.includes('server.ts')) {
      process.stderr.write(`telegram channel: replacing stale poller pid=${stale}\n`)
      process.kill(stale, 'SIGTERM')                                                    // never reached
    }
  }
} catch {}
writeFileSync(PID_FILE, String(process.pid))                                            // line 78 — runs anyway

Reproduction

The guard reduced to a standalone script, run under the same bun that the plugin uses. process.kill(pid, 0) is verified to behave correctly on Windows, so the failure is isolated to the ps call:

// bun repro.js
const { execFileSync } = require("child_process");
const pid = parseInt(execFileSync("powershell.exe",
  ["-NoProfile","-Command","(Get-Process explorer | Select-Object -First 1).Id"],
  {encoding:"utf8"}).trim(), 10);

try { process.kill(pid, 0); console.log("kill(pid,0): OK"); }
catch (e) { console.log("kill(pid,0) threw:", e.code); }

try { const c = execFileSync("ps", ["-p", String(pid), "-o", "args="], {encoding:"utf8", stdio:["ignore","pipe","ignore"]});
      console.log("ps ok:", JSON.stringify(c)); }
catch (e) { console.log("ps threw: status =", e.status); }

Output:

kill(pid,0): OK
ps threw: status = 1

kill(pid, 0) also correctly reports ESRCH for a PID that has since exited, so the existence check itself is sound on Windows — only the command-line verification is broken.

Observed in practice

Two Claude Code instances sharing one CLAUDE_CONFIG_DIR, both with the plugin enabled globally:

20524 claude.exe  21:49:12   Claude Desktop
 └ 17820 bun      21:49:17
    └ 29736 bun   21:49:17   poller #1

13916 claude.exe  21:50:19   CLI
 └ 33260 bun      21:50:21
    └ 29132 bun   21:50:22   poller #2

bot.pid contained 29132, while 29736 was still alive and holding the slot — both processes had established connections to 149.154.166.110:443. No replacing stale poller pid=… line was ever emitted. Inbound messages did not arrive in either session, and getUpdates from outside returned {"ok":true,"result":[]} with pending_update_count: 0 — consistent with #83948, where the loser burns its eight 409 retries in ~28 s and goes permanently deaf while /mcp still reports connected.

Suggested fix

Verify the holder's command line without depending on a POSIX ps. Options, roughly in order of preference:

  1. Drop the command-line check on Windows and rely on a stronger identity token instead — e.g. write {pid, procStartFileTime, token-hash} to the PID file and compare the process start time, which defeats PID recycling more reliably than a string match on args. Claude Code's own session registry (sessions/<pid>.json) already stores procStart for exactly this purpose.
  2. If a command-line lookup is kept, branch on platform: Get-CimInstance Win32_Process -Filter "ProcessId=<pid>" | Select-Object -ExpandProperty CommandLine (or wmic process where processid=<pid> get commandline) on win32, ps elsewhere.
  3. At minimum, stop swallowing the failure silently: log to stderr when the verification cannot run, and do not overwrite bot.pid when the previous holder is alive but could not be evicted — the current behavior destroys the only breadcrumb pointing at the real poller.

Related

  • #83948 — poller gives up on 409 after ~28 s and never recovers (this bug is one way to reach that state, deterministically, on Windows)
  • #84108 — sessions without --channels still start a poller (same Windows 11 build, same global enabledPlugins setup)
  • #81571 — starting a second session kills the running poller (the inverse symptom on platforms where SIGTERM does land)
  • #66106 (closed) — PID_FILE assumes single writer

View original on GitHub ↗