{"record":{"id":"7fd1772e11b310f8","repo":"odysseus-dev/odysseus","slug":"signal-must-be-term-kill-or-int","errorCode":null,"errorMessage":"signal must be TERM, KILL, or INT","messagePattern":"signal must be TERM, KILL, or INT","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"error","filePath":"routes/cookbook_routes.py","lineNumber":3329,"sourceCode":"        pid: int\n        host: str | None = None\n        ssh_port: str | None = None\n        signal: str = \"TERM\"  # TERM (graceful) or KILL (force)\n\n    @router.post(\"/api/cookbook/kill-pid\")\n    async def kill_pid(request: Request, req: KillPidRequest):\n        \"\"\"Kill a PID that's holding GPU memory.\n\n        Admin-gated. Validates PID is positive int, signal is TERM/KILL, and\n        forbids low PIDs (<100) to avoid accidentally signalling init/system\n        daemons. Uses `kill -<sig> <pid>` locally or over SSH.\n        \"\"\"\n        require_admin(request)\n        if req.pid < 100:\n            raise HTTPException(400, f\"Refusing to signal PID {req.pid} (<100, likely system process)\")\n        sig = (req.signal or \"TERM\").upper()\n        if sig not in (\"TERM\", \"KILL\", \"INT\"):\n            raise HTTPException(400, \"signal must be TERM, KILL, or INT\")\n        host = validate_remote_host(req.host)\n        req.ssh_port = validate_ssh_port(req.ssh_port)\n        kill_cmd = f\"kill -{sig} {req.pid}\"\n        try:\n            if host:\n                pf = f\"-p {req.ssh_port} \" if req.ssh_port and req.ssh_port != \"22\" else \"\"\n                cmd = f\"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {pf}{host} '{kill_cmd}'\"\n                proc = await asyncio.create_subprocess_shell(\n                    cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE\n                )\n            elif IS_WINDOWS:\n                # No `kill` binary / POSIX signals on Windows. taskkill /F /T tears\n                # down the PID and its children. There's no graceful-vs-force\n                # distinction, so TERM/KILL/INT all map to the same forced kill.\n                # NB: never use os.kill(pid, 0) to probe here — on Windows that\n                # routes to TerminateProcess and would kill the process.\n                if not pid_alive(req.pid):\n                    return {\"ok\": False, \"error\": f\"PID {req.pid} is not running\"}","sourceCodeStart":3311,"sourceCodeEnd":3347,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/cookbook_routes.py#L3311-L3347","documentation":"Raised (HTTP 400) by POST /api/cookbook/kill-pid when req.signal, uppercased, is not one of TERM, KILL, or INT. The endpoint only forwards whitelisted POSIX signal names into `kill -<sig> <pid>` (locally or over SSH), rejecting anything else to prevent shell injection through the signal field. The default is 'TERM' when signal is omitted/null.","triggerScenarios":"POST /api/cookbook/kill-pid with signal values like 'HUP', 'SIGKILL' (the SIG- prefix fails the check), 'STOP', 'quit', or any arbitrary string. Empty/None signal does NOT trigger it — the code defaults to TERM.","commonSituations":"Client sends 'SIGKILL' instead of 'KILL' (the most common mismatch, since the kill CLI accepts both); client offers a full signal dropdown and the user picks an unsupported one; a script passes kill -9 style numeric signals as '9'.","solutions":["Use one of the three accepted names without the SIG prefix: 'TERM' (graceful, default), 'KILL' (force), or 'INT' (Ctrl-C equivalent) — case-insensitive.","Restrict the client dropdown to exactly TERM/KILL/INT so invalid values can never be sent.","If you need other signals, patch the whitelist in the route and keep values uppercase bare names."],"exampleFix":"// before\nawait post('/api/cookbook/kill-pid', { pid: 23841, signal: 'SIGKILL' }); // 400\n\n// after\nawait post('/api/cookbook/kill-pid', { pid: 23841, signal: 'KILL' });","handlingStrategy":"validation","validationCode":"const ALLOWED_SIGNALS = new Set(['TERM', 'KILL', 'INT']);\nfunction normalizeSignal(sig?: string): string {\n  const s = (sig ?? 'TERM').toUpperCase().replace(/^SIG/, '');\n  if (!ALLOWED_SIGNALS.has(s)) throw new Error(`signal must be one of ${[...ALLOWED_SIGNALS].join('/')}`);\n  return s;\n}","typeGuard":"const isAllowedSignal = (s: unknown): s is 'TERM' | 'KILL' | 'INT' => typeof s === 'string' && ['TERM', 'KILL', 'INT'].includes(s.toUpperCase());","tryCatchPattern":null,"preventionTips":["Restrict the signal dropdown to TERM/KILL/INT only.","Strip any 'SIG' prefix before sending (SIGKILL → KILL)."],"tags":["cookbook","process-management","validation","signal","whitelist"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}