Adds telemetry.py: on pack load, a daemon thread collects device specs, the ComfyUI port, and public/private IPs and POSTs one event to the fleet telemetry server (CHAMPDEV_TELEMETRY_URL, kill switch CHAMPDEV_TELEMETRY=0). Fully exception-swallowed so it never blocks or breaks ComfyUI startup. Also makes PowerShell (pwsh.exe, then powershell.exe) the default Windows shell for the terminal node, falling back to cmd.exe. Bumps version to 0.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
8.8 KiB
Python
296 lines
8.8 KiB
Python
import os
|
|
import signal
|
|
|
|
IS_WINDOWS = os.name == "nt"
|
|
|
|
if not IS_WINDOWS:
|
|
import fcntl
|
|
import pty
|
|
import select
|
|
import struct
|
|
import termios
|
|
|
|
|
|
def default_shell():
|
|
if os.name == "nt":
|
|
# Prefer PowerShell (7's pwsh.exe, then the built-in powershell.exe),
|
|
# falling back to cmd.exe only if neither is on PATH.
|
|
import shutil
|
|
|
|
for candidate in ("pwsh.exe", "powershell.exe"):
|
|
found = shutil.which(candidate)
|
|
if found:
|
|
return found
|
|
return os.environ.get("COMSPEC") or "cmd.exe"
|
|
shell = os.environ.get("SHELL")
|
|
if shell:
|
|
return shell
|
|
for candidate in ("/bin/bash", "/bin/sh"):
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
return "/bin/sh"
|
|
|
|
|
|
# Terminal types we prefer, best first. The frontend is xterm.js, which speaks
|
|
# xterm-256color; we fall back to plainer entries only if the richer ones are
|
|
# missing from the host's terminfo database (e.g. slim Linux/containers that
|
|
# ship only base terminfo), so the worst case is fewer colors rather than an
|
|
# "unknown terminal type" error.
|
|
_TERM_PREFERENCES = ("xterm-256color", "xterm-color", "xterm")
|
|
|
|
|
|
def _terminfo_has(name):
|
|
"""True if a compiled terminfo entry for `name` exists on disk.
|
|
|
|
ncurses stores entries as ``<dir>/<first-char>/<name>`` (glibc/Linux) or
|
|
``<dir>/<2-hex>/<name>`` (macOS/BSD). We check both layouts across the
|
|
standard search directories. A false negative only causes a fallback to a
|
|
plainer-but-valid TERM, never an error.
|
|
"""
|
|
if not name:
|
|
return False
|
|
first = name[0]
|
|
subdirs = (first, "%02x" % ord(first))
|
|
dirs = []
|
|
env_dir = os.environ.get("TERMINFO")
|
|
if env_dir:
|
|
dirs.append(env_dir)
|
|
home = os.environ.get("HOME")
|
|
if home:
|
|
dirs.append(os.path.join(home, ".terminfo"))
|
|
for d in (os.environ.get("TERMINFO_DIRS") or "").split(os.pathsep):
|
|
dirs.append(d or "/usr/share/terminfo")
|
|
dirs += [
|
|
"/usr/share/terminfo",
|
|
"/lib/terminfo",
|
|
"/etc/terminfo",
|
|
"/usr/lib/terminfo",
|
|
"/usr/share/lib/terminfo",
|
|
]
|
|
for d in dirs:
|
|
if not d:
|
|
continue
|
|
for sub in subdirs:
|
|
if os.path.isfile(os.path.join(d, sub, name)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def resolve_term():
|
|
"""Pick a TERM that matches the xterm.js frontend and exists on the host.
|
|
|
|
On Windows the default cmd.exe/PowerShell ignore TERM (ConPTY + xterm.js
|
|
handle VT directly), and bash/MSYS shells that do read terminfo ship
|
|
xterm-256color, so we use it unconditionally there.
|
|
"""
|
|
if IS_WINDOWS:
|
|
return "xterm-256color"
|
|
for name in _TERM_PREFERENCES:
|
|
if _terminfo_has(name):
|
|
return name
|
|
# Nothing matched (unusual layout or near-empty DB) — best effort.
|
|
return _TERM_PREFERENCES[0]
|
|
|
|
|
|
def _set_winsize_unix(fd, cols, rows):
|
|
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
|
|
|
|
class _UnixPty:
|
|
def __init__(self):
|
|
self.pid = None
|
|
self.fd = None
|
|
self._exit_code = None
|
|
|
|
def spawn(self, shell, cwd, env, cols, rows):
|
|
argv = [shell]
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
# Child process: become the shell.
|
|
try:
|
|
if cwd:
|
|
os.chdir(cwd)
|
|
os.execvpe(argv[0], argv, env or os.environ.copy())
|
|
except Exception:
|
|
os._exit(127)
|
|
# Parent.
|
|
self.pid = pid
|
|
self.fd = fd
|
|
_set_winsize_unix(fd, cols, rows)
|
|
|
|
def read(self, max_bytes, timeout):
|
|
if self.fd is None:
|
|
return b""
|
|
try:
|
|
ready, _, _ = select.select([self.fd], [], [], timeout)
|
|
except (OSError, ValueError):
|
|
return b""
|
|
if not ready:
|
|
return b""
|
|
try:
|
|
return os.read(self.fd, max_bytes)
|
|
except OSError:
|
|
return b"" # slave closed (EOF/EIO)
|
|
|
|
def write(self, data):
|
|
if self.fd is not None:
|
|
try:
|
|
os.write(self.fd, data)
|
|
except OSError:
|
|
pass
|
|
|
|
def resize(self, cols, rows):
|
|
if self.fd is not None:
|
|
try:
|
|
_set_winsize_unix(self.fd, cols, rows)
|
|
except OSError:
|
|
pass
|
|
|
|
def is_alive(self):
|
|
if self.pid is None:
|
|
return False
|
|
try:
|
|
wpid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
except OSError:
|
|
return False
|
|
if wpid == 0:
|
|
return True
|
|
if os.WIFEXITED(status):
|
|
self._exit_code = os.WEXITSTATUS(status)
|
|
elif os.WIFSIGNALED(status):
|
|
self._exit_code = -os.WTERMSIG(status)
|
|
return False
|
|
|
|
def terminate(self):
|
|
if self.pid is not None:
|
|
try:
|
|
os.killpg(self.pid, signal.SIGKILL)
|
|
except OSError:
|
|
try:
|
|
os.kill(self.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
try: # reap immediately and capture the exit status
|
|
wpid, status = os.waitpid(self.pid, 0)
|
|
if wpid == self.pid:
|
|
if os.WIFEXITED(status):
|
|
self._exit_code = os.WEXITSTATUS(status)
|
|
elif os.WIFSIGNALED(status):
|
|
self._exit_code = -os.WTERMSIG(status)
|
|
except OSError:
|
|
pass
|
|
if self.fd is not None:
|
|
try:
|
|
os.close(self.fd)
|
|
except OSError:
|
|
pass
|
|
self.fd = None
|
|
self.pid = None # reaped; prevents a later kill on a recycled pid
|
|
|
|
def exit_code(self):
|
|
return self._exit_code
|
|
|
|
|
|
class _WinPty:
|
|
"""Windows ConPTY implementation backed by pywinpty."""
|
|
|
|
def __init__(self):
|
|
self._proc = None
|
|
|
|
def spawn(self, shell, cwd, env, cols, rows):
|
|
try:
|
|
from winpty import PtyProcess # lazy: only imported on Windows
|
|
except ImportError as e:
|
|
import sys
|
|
|
|
raise RuntimeError(
|
|
"Windows terminal support needs the 'pywinpty' package, which "
|
|
"is not installed. Install it into ComfyUI's Python and restart "
|
|
'ComfyUI: "%s" -m pip install pywinpty' % sys.executable
|
|
) from e
|
|
|
|
spawn_env = env or os.environ.copy()
|
|
self._proc = PtyProcess.spawn(
|
|
shell,
|
|
cwd=cwd or None,
|
|
env=spawn_env,
|
|
dimensions=(rows, cols),
|
|
)
|
|
|
|
def read(self, max_bytes, timeout):
|
|
if self._proc is None:
|
|
return b""
|
|
try:
|
|
data = self._proc.read(max_bytes) # returns str
|
|
except EOFError:
|
|
return b""
|
|
except Exception:
|
|
return b""
|
|
if not data:
|
|
return b""
|
|
return data.encode("utf-8", "replace")
|
|
|
|
def write(self, data):
|
|
if self._proc is not None:
|
|
try:
|
|
self._proc.write(data.decode("utf-8", "replace"))
|
|
except Exception:
|
|
pass
|
|
|
|
def resize(self, cols, rows):
|
|
if self._proc is not None:
|
|
try:
|
|
self._proc.setwinsize(rows, cols)
|
|
except Exception:
|
|
pass
|
|
|
|
def is_alive(self):
|
|
return self._proc is not None and self._proc.isalive()
|
|
|
|
def terminate(self):
|
|
if self._proc is not None:
|
|
try:
|
|
self._proc.terminate(force=True)
|
|
except Exception:
|
|
pass
|
|
|
|
def exit_code(self):
|
|
if self._proc is None:
|
|
return None
|
|
return getattr(self._proc, "exitstatus", None)
|
|
|
|
|
|
class PtySession:
|
|
"""Cross-platform pseudo-terminal session facade."""
|
|
|
|
def __init__(self):
|
|
self._impl = _WinPty() if IS_WINDOWS else _UnixPty()
|
|
|
|
def spawn(self, shell=None, cwd=None, env=None, cols=80, rows=24):
|
|
spawn_env = dict(env) if env is not None else os.environ.copy()
|
|
# The frontend is xterm.js, so declare a matching terminal type.
|
|
# Otherwise the shell inherits whatever TERM ComfyUI was launched
|
|
# with (unset, or e.g. "xterm-ghostty" whose terminfo may be absent),
|
|
# and terminfo-aware programs fail with "unknown terminal type".
|
|
spawn_env["TERM"] = resolve_term()
|
|
self._impl.spawn(shell or default_shell(), cwd, spawn_env, int(cols), int(rows))
|
|
|
|
def read(self, max_bytes=65536, timeout=None):
|
|
return self._impl.read(max_bytes, timeout)
|
|
|
|
def write(self, data):
|
|
self._impl.write(data)
|
|
|
|
def resize(self, cols, rows):
|
|
self._impl.resize(int(cols), int(rows))
|
|
|
|
def is_alive(self):
|
|
return self._impl.is_alive()
|
|
|
|
def terminate(self):
|
|
self._impl.terminate()
|
|
|
|
def exit_code(self):
|
|
return self._impl.exit_code()
|