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
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:
- 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 onargs. Claude Code's own session registry (sessions/<pid>.json) already storesprocStartfor exactly this purpose. - If a command-line lookup is kept, branch on platform:
Get-CimInstance Win32_Process -Filter "ProcessId=<pid>" | Select-Object -ExpandProperty CommandLine(orwmic process where processid=<pid> get commandline) onwin32,pselsewhere. - At minimum, stop swallowing the failure silently: log to stderr when the verification cannot run, and do not overwrite
bot.pidwhen 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
--channelsstill start a poller (same Windows 11 build, same globalenabledPluginssetup) - #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