|
1 | 1 | """Command handlers for slash commands and bash execution.""" |
2 | 2 |
|
| 3 | +import os |
| 4 | +import signal |
3 | 5 | import subprocess |
4 | 6 | from pathlib import Path |
5 | 7 |
|
@@ -57,33 +59,92 @@ def execute_bash_command(command: str) -> bool: |
57 | 59 | if not cmd: |
58 | 60 | return True |
59 | 61 |
|
| 62 | + process = None |
| 63 | + interrupted = False |
| 64 | + original_handler = None |
| 65 | + |
| 66 | + def sigint_handler(signum, frame): |
| 67 | + """Custom SIGINT handler - kills subprocess immediately and sets flag.""" |
| 68 | + nonlocal interrupted, process |
| 69 | + interrupted = True |
| 70 | + |
| 71 | + # Kill subprocess immediately when Ctrl+C is pressed |
| 72 | + if process and process.poll() is None: |
| 73 | + try: |
| 74 | + if hasattr(os, "killpg"): |
| 75 | + os.killpg(os.getpgid(process.pid), signal.SIGINT) |
| 76 | + else: |
| 77 | + process.send_signal(signal.SIGINT) |
| 78 | + except (ProcessLookupError, OSError): |
| 79 | + pass |
| 80 | + |
60 | 81 | try: |
| 82 | + # Install our signal handler (temporarily overrides asyncio's handler) |
| 83 | + original_handler = signal.signal(signal.SIGINT, sigint_handler) |
| 84 | + |
61 | 85 | console.print() |
62 | 86 | console.print(f"[dim]$ {cmd}[/dim]") |
63 | 87 |
|
64 | | - # Execute the command |
65 | | - result = subprocess.run( |
66 | | - cmd, check=False, shell=True, capture_output=True, text=True, timeout=30, cwd=Path.cwd() |
| 88 | + # Create subprocess in new session (process group) to isolate from parent's signals |
| 89 | + process = subprocess.Popen( |
| 90 | + cmd, |
| 91 | + shell=True, |
| 92 | + text=True, |
| 93 | + stdin=subprocess.DEVNULL, # Close stdin to prevent interactive commands from hanging |
| 94 | + stdout=subprocess.PIPE, |
| 95 | + stderr=subprocess.PIPE, |
| 96 | + start_new_session=True, # Isolate subprocess in its own process group |
| 97 | + cwd=Path.cwd(), |
67 | 98 | ) |
68 | 99 |
|
69 | | - # Display output |
70 | | - if result.stdout: |
71 | | - console.print(result.stdout, style=COLORS["dim"], markup=False) |
72 | | - if result.stderr: |
73 | | - console.print(result.stderr, style="red", markup=False) |
74 | | - |
75 | | - # Show return code if non-zero |
76 | | - if result.returncode != 0: |
77 | | - console.print(f"[dim]Exit code: {result.returncode}[/dim]") |
| 100 | + try: |
| 101 | + # Wait for command to complete with timeout |
| 102 | + stdout, stderr = process.communicate(timeout=30) |
| 103 | + |
| 104 | + # Display output |
| 105 | + if stdout: |
| 106 | + console.print(stdout, style=COLORS["dim"], markup=False) |
| 107 | + if stderr: |
| 108 | + console.print(stderr, style="red", markup=False) |
| 109 | + |
| 110 | + # Check if interrupted via our flag |
| 111 | + if interrupted: |
| 112 | + console.print("\n[yellow]Command interrupted by user[/yellow]\n") |
| 113 | + elif process.returncode != 0: |
| 114 | + # Exit code 130 = 128 + SIGINT (2) - command was interrupted |
| 115 | + # Exit code -2 also indicates interrupt in some shells |
| 116 | + if process.returncode == 130 or process.returncode == -2: |
| 117 | + console.print("[yellow]Command interrupted[/yellow]") |
| 118 | + else: |
| 119 | + console.print(f"[dim]Exit code: {process.returncode}[/dim]") |
| 120 | + |
| 121 | + except subprocess.TimeoutExpired: |
| 122 | + # Timeout - kill the process group |
| 123 | + if hasattr(os, "killpg"): |
| 124 | + try: |
| 125 | + os.killpg(os.getpgid(process.pid), signal.SIGKILL) |
| 126 | + except (ProcessLookupError, OSError): |
| 127 | + pass |
| 128 | + else: |
| 129 | + process.kill() |
| 130 | + |
| 131 | + # Clean up zombie process |
| 132 | + try: |
| 133 | + process.wait(timeout=1) |
| 134 | + except subprocess.TimeoutExpired: |
| 135 | + pass |
| 136 | + |
| 137 | + console.print("[red]Command timed out after 30 seconds[/red]") |
78 | 138 |
|
79 | 139 | console.print() |
80 | 140 | return True |
81 | 141 |
|
82 | | - except subprocess.TimeoutExpired: |
83 | | - console.print("[red]Command timed out after 30 seconds[/red]") |
84 | | - console.print() |
85 | | - return True |
86 | 142 | except Exception as e: |
87 | 143 | console.print(f"[red]Error executing command: {e}[/red]") |
88 | 144 | console.print() |
89 | 145 | return True |
| 146 | + |
| 147 | + finally: |
| 148 | + # CRITICAL: Always restore original signal handler so asyncio can handle Ctrl+C at prompt |
| 149 | + if original_handler is not None: |
| 150 | + signal.signal(signal.SIGINT, original_handler) |
0 commit comments