{"repo":"NousResearch/hermes-agent","public_id":"54201","display_label":"Windows terminal hangs when launching GUI processes","summary":"These items address an issue where terminal sessions on Windows become unresponsive when GUI programs are launched, and propose or implement killing the entire process tree to prevent the hang.","subsystem":"environments","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55547,"merged_at":"2026-06-30T09:28:34+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-30T09:28:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":54201,"pr_number":55547,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-30T09:28:34+00:00"},{"issue_number":54744,"pr_number":55547,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T09:28:34+00:00"}],"members":[{"kind":"issue","number":54201,"title":"[Windows] terminal tool hangs when launching GUI programs via .exe or start command — session becomes unresponsive","url":null,"state":"closed"},{"kind":"pr","number":54235,"title":"fix: kill entire process tree on Windows to prevent GUI program hang (#54201)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e5253d852b24f87af91d77b66141cc5c0b9e6f69","number":0},{"kind":"pr","value":"#55547","number":55547}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55547,"title":"fix(desktop): tree-kill Windows terminal descendants","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs","apps/desktop/electron/windows-child-process.test.cjs","tests/tools/test_local_interrupt_cleanup.py","tests/tools/test_process_registry.py","tools/environments/local.py","tools/process_registry.py"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -5108,13 +5108,24 @@ function resetBootProgressForReconnect() {\n   )\n }\n \n+function stopBackendChild(child) {\n+  if (!child || child.killed) return\n+  try {\n+    if (IS_WINDOWS && Number.isInteger(child.pid)) {\n+      forceKillProcessTree(child.pid)\n+    } else {\n+      child.kill('SIGTERM')\n+    }\n+  } catch {\n+    // Already gone.\n+  }\n+}\n+\n function resetHermesConnection() {\n   connectionPromise = null\n   backendStartFailure = null\n \n-  if (hermesProcess && !hermesProcess.killed) {\n-    hermesProcess.kill('SIGTERM')\n-  }\n+  stopBackendChild(hermesProcess)\n \n   hermesProcess = null\n   resetBootProgressForReconnect()","@@ -5362,13 +5373,7 @@ function stopPoolBackend(profile) {\n   const entry = backendPool.get(profile)\n   if (!entry) return\n   backendPool.delete(profile)\n-  if (entry.process && !entry.process.killed) {\n-    try {\n-      entry.process.kill('SIGTERM')\n-    } catch {\n-      // Already gone.\n-    }\n-  }\n+  stopBackendChild(entry.process)\n }\n \n async function teardownPoolBackendAndWait(profile) {","@@ -5376,13 +5381,7 @@ async function teardownPoolBackendAndWait(profile) {\n   if (!entry) return\n   backendPool.delete(profile)\n \n-  if (entry.process && !entry.process.killed) {\n-    try {\n-      entry.process.kill('SIGTERM')\n-    } catch {\n-      // Already gone.\n-    }\n-  }\n+  stopBackendChild(entry.process)\n \n   await waitForBackendExit(entry.process)\n }","@@ -7600,9 +7599,7 @@ app.on('before-quit', () => {\n     disposeTerminalSession(id)\n   }\n \n-  if (hermesProcess && !hermesProcess.killed) {\n-    hermesProcess.kill('SIGTERM')\n-  }\n+  stopBackendChild(hermesProcess)\n   stopAllPoolBackends()\n })\n "]},{"file":"apps/desktop/electron/windows-child-process.test.cjs","hunks":["@@ -74,6 +74,29 @@ test('desktop backend launches console python so child consoles are inherited, n\n   requireHiddenChildOptions(source, /hermesProcess = spawn\\(\\s*backend\\.command,\\s*backend\\.args/)\n })\n \n+test('desktop backend teardown tree-kills Windows backend descendants', () => {\n+  const source = readElectronFile('main.cjs')\n+\n+  const helperIndex = source.indexOf('function stopBackendChild(child)')\n+  assert.notEqual(helperIndex, -1, 'missing backend teardown helper')\n+  const helperSnippet = source.slice(helperIndex, helperIndex + 500)\n+  assert.match(helperSnippet, /IS_WINDOWS && Number\\.isInteger\\(child\\.pid\\)/)\n+  assert.match(helperSnippet, /forceKillProcessTree\\(child\\.pid\\)/)\n+  assert.match(helperSnippet, /child\\.kill\\('SIGTERM'\\)/)\n+\n+  const resetIndex = source.indexOf('function resetHermesConnection()')\n+  assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')\n+  const resetSnippet = source.slice(resetIndex, resetIndex + 300)\n+  assert.match(resetSnippet, /stopBackendChild\\(hermesProcess\\)/)\n+  assert.doesNotMatch(resetSnippet, /hermesProcess\\.kill\\('SIGTERM'\\)/)\n+\n+  const quitIndex = source.indexOf(\"app.on('before-quit'\")\n+  assert.notEqual(quitIndex, -1, 'missing before-quit handler')\n+  const quitSnippet = source.slice(quitIndex, quitIndex + 900)\n+  assert.match(quitSnippet, /stopBackendChild\\(hermesProcess\\)/)\n+  assert.doesNotMatch(quitSnippet, /hermesProcess\\.kill\\('SIGTERM'\\)/)\n+})\n+\n test('intentional or interactive desktop child processes stay documented', () => {\n   const source = readElectronFile('main.cjs')\n "]},{"file":"tests/tools/test_local_interrupt_cleanup.py","hunks":["@@ -20,6 +20,7 @@\n \n import pytest\n \n+from tools.environments import local as local_mod\n from tools.environments.local import LocalEnvironment\n \n ","@@ -96,6 +97,32 @@ def fake_killpg(pgid, sig):\n     assert killpg_calls == [(67890, signal.SIGTERM), (67890, 0)]\n \n \n+def test_kill_process_uses_windows_tree_kill(monkeypatch):\n+    \"\"\"Windows must kill the whole Bash process tree, not just the wrapper.\"\"\"\n+    env = object.__new__(LocalEnvironment)\n+    terminate_calls = []\n+    waits = []\n+    killed = []\n+\n+    def fake_terminate(pid, *, force=False):\n+        terminate_calls.append((pid, force))\n+\n+    proc = SimpleNamespace(\n+        pid=12345,\n+        kill=lambda: killed.append(True),\n+        wait=lambda timeout=None: waits.append(timeout),\n+    )\n+\n+    monkeypatch.setattr(local_mod, \"_IS_WINDOWS\", True)\n+    monkeypatch.setattr(\"gateway.status.terminate_pid\", fake_terminate)\n+\n+    env._kill_process(proc)\n+\n+    assert terminate_calls == [(12345, True)]\n+    assert waits == [2.0]\n+    assert killed == []\n+\n+\n def test_wait_for_process_kills_subprocess_on_keyboardinterrupt():\n     \"\"\"When KeyboardInterrupt arrives mid-poll, the subprocess group must be\n     killed before the exception is re-raised.\"\"\""]},{"file":"tests/tools/test_process_registry.py","hunks":["@@ -1103,6 +1103,26 @@ def test_kill_already_exited(self, registry):\n         result = registry.kill_process(s.id)\n         assert result[\"status\"] == \"already_exited\"\n \n+    def test_kill_local_popen_uses_host_tree_terminator(self, registry, monkeypatch):\n+        s = _make_session(sid=\"proc_local\", command=\"sleep 999\")\n+        s.process = MagicMock()\n+        s.process.pid = 12345\n+        s.host_start_time = 67890\n+        registry._running[s.id] = s\n+        terminate_calls = []\n+\n+        monkeypatch.setattr(\n+            registry,\n+            \"_terminate_host_pid\",\n+            lambda pid, expected_start=None: terminate_calls.append((pid, expected_start)),\n+        )\n+        monkeypatch.setattr(registry, \"_write_checkpoint\", lambda: None)\n+\n+        result = registry.kill_process(s.id)\n+\n+        assert result[\"status\"] == \"killed\"\n+        assert terminate_calls == [(12345, 67890)]\n+\n     def test_kill_detached_session_uses_host_pid(self, registry):\n         s = _make_session(sid=\"proc_detached\", command=\"sleep 999\")\n         s.pid = 424242"]},{"file":"tools/environments/local.py","hunks":["@@ -892,7 +892,16 @@ def _wait_for_group_exit(pgid: int, timeout: float) -> bool:\n \n         try:\n             if _IS_WINDOWS:\n-                proc.terminate()\n+                try:\n+                    from gateway.status import terminate_pid\n+\n+                    terminate_pid(proc.pid, force=True)\n+                except Exception:\n+                    proc.kill()\n+                try:\n+                    proc.wait(timeout=2.0)\n+                except (subprocess.TimeoutExpired, OSError):\n+                    pass\n             else:\n                 try:\n                     pgid = os.getpgid(proc.pid)"]},{"file":"tools/process_registry.py","hunks":["@@ -1433,24 +1433,10 @@ def kill_process(self, session_id: str, *, source: str = \"process.kill\") -> dict\n                     if session.pid:\n                         os.kill(session.pid, signal.SIGTERM)\n             elif session.process:\n-                # Local process -- kill the process tree\n-                try:\n-                    if _IS_WINDOWS:\n-                        session.process.terminate()\n-                    else:\n-                        import psutil\n-                        try:\n-                            parent = psutil.Process(session.process.pid)\n-                            for child in parent.children(recursive=True):\n-                                try:\n-                                    child.terminate()\n-                                except psutil.NoSuchProcess:\n-                                    pass\n-                            parent.terminate()\n-                        except psutil.NoSuchProcess:\n-                            pass\n-                except (ProcessLookupError, PermissionError):\n-                    session.process.kill()\n+                # Local process -- kill the process tree. On Windows this\n+                # must be taskkill /T /F; Popen.terminate() only kills the\n+                # shell wrapper and leaves Git Bash descendants behind.\n+                self._terminate_host_pid(session.process.pid, session.host_start_time)\n             elif session.env_ref and session.pid:\n                 # Non-local -- kill inside sandbox\n                 session.env_ref.execute(f\"kill {session.pid} 2>/dev/null\", timeout=5)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}