tech(KF-1): der Beenden-Pfad nimmt jetzt auch den Tunnel mit
Mein eigener Fund bei der Kontrolle, in keinem der beiden Audits. `AppState::shutdown_subprocesses` (`state.rs:462`) enthielt kein Vorkommen von `remote`, `RemoteServer`, `tunnel` oder `cloudflared`. Ollama, ComfyUI, llama-server, Embeddings-Server, Trainer und MLX-Sidecar standen dort -- der Tunnel hing allein an `Drop for RemoteServer`. Der Kommentar ueber jenem Drop gestand es selbst: "that path lives outside this module and does not reach the tunnel". Und die Methode existiert ueberhaupt nur, weil Tauri v2 Destruktoren ueberspringen kann. `RemoteServer::shutdown_tunnel()` ist jetzt der eine Eintrittspunkt fuer "die App geht weg"; `Drop` ruft dieselbe Methode statt einer zweiten Kopie. **Kein Doppel-Kill:** `take()` wie jeder andere Slot -- der zweite Durchlauf findet `None`. Das ist hier nicht bloss ueberfluessig: `kill_tree`s Schnappschuss nimmt die Wurzel mit auf, ein zweiter Schlag ginge auf eine womoeglich recycelte pid, unter Windows als `taskkill /T /F` auf einen fremden Baum. **Vergiftetes Mutex** wird hier per `PoisonError::into_inner` gerettet statt wie bei den Nachbarn uebersprungen. Begruendung des Agenten, die ich teile: bei Ollama ist ein uebersprungener Kill ein Leck, hier ein Sicherheitsereignis -- und ein `Option<Child>` herauszunehmen kann keine kaputte Invariante beobachten. ## Was ich selbst gefunden habe **Die Reihenfolge ist begruendet, aber nicht festgenagelt.** Der Agent argumentiert gut, warum der Tunnel *zuerst* sterben muss: solange er steht, erreicht das Internet den Remote-Server, der zu genau den Daemons durchreicht, die der Rest der Funktion gerade abraeumt -- zuletzt hiesse, die Tuer bleibt ueber jede blockierende Wartezeit darunter offen. Meine Sonde: den Aufruf ans Ende der Funktion verschoben -> **alle 774 Tests bleiben gruen.** Nachtrag ist beauftragt. Das ist der zweite Fall desselben Musters in dieser Sitzung (siehe `4bb21590`): sorgfaeltig begruendete Reihenfolge, sauber kommentiert, von nichts erzwungen. ## Und ein Flake, den ich dabei gemessen habe `remote_hardening_tests::the_sweep_finds_a_real_cloudflared_by_its_argv` faellt unter Last aus. Gemessen: zweimal rot bei unveraendertem Code waehrend eines vollen Neubaus; danach in Isolation 10/10 gruen und im vollen Lauf 5/5 gruen. Der Test startet einen echten Prozess und durchsucht die Tabelle in einem Zeitbudget, das unter Last nicht reicht. Als Befund benannt, nicht repariert. ## Nachzuege `shutdown.ts`: der Absatz "ein `kill_tree` auf der Rust-Seite ist ausser Reichweite" stimmte seit `c5773322`/`4c7bf748` nicht mehr. Nachgezogen, nur Kommentar; dazu die rottende Zeilennummer `state.rs:462` entfernt. `mobile-client/`: die 5 eslint-Fehler sind weg (0 Meldungen). Der vorige Agent hatte sie eingefroren, um die Byte-Gleichheit zu beweisen; der Beweis steht, also durften sie fallen. **Der Nachweis fuer die vier Regex-Escapes ist erschoepfend gefuehrt:** je Escape die Klammerbilanz (IdentityEscape in einer CharacterClass), dann `^[!,\.]$` gegen `^[!,.]$` ueber **alle 65.536 Code-Units** mit und ohne `/i` -- ohne `u`-Flag kann ein Klassenatom nur eine Code-Unit matchen, das ist vollstaendig, nicht stichprobenhaft -- und schliesslich die ganze Regex differenziell ueber 578.829 Strings. Null Abweichungen. Von mir nachgerechnet: die ausgelieferte Seite geht 182.560 -> 182.512 Bytes, **genau -48** = 4 Backslashes + 44 Bytes tote Zeile. Der `diff` der assemblierten Seite zeigt exakt fuenf Stellen und sonst nichts. Neu: `mobile-echo-guard-punctuation.test.ts` nagelt fest, dass die Klasse alle drei Satzzeichen akzeptiert **und kein viertes**. Der bestehende Paritaetstest kannte nur `!` und `,` -- der Punkt kam dort nie vor. ## Grenzen Kein echtes cloudflared (Stellvertreter, vom Test selbst gestartet). `sysinfo::Process::kill()` bleibt ungetestet, wie vom Vorgaenger getrennt. Der Windows-Zweig (`taskkill /T /F`, Job-Objekt, pid-Recycling) ist auf macOS nicht ausfuehrbar -- dort traegt nur der reine Entscheidungstest. Dass Tauris `RunEvent::Exit` die Methode im gepackten Binary erreicht, ist nur durch die Verdrahtung belegt. Und der Escape-Nachweis lief in V8, nicht in der WebView des Telefons -- das Spezifikations-Argument gilt motorunabhaengig, die Messung nicht. 3 Sonden des Agenten + 3 eigene. cargo test 774/0/3 · tsc 0 · vitest 7748 gruen / 0 rot · eslint mobile-client sauber.
This commit is contained in:
1 parent
4bb2159070
commit
067a3c2baf
6 files changed
+296
-25
No files matched your search
@@ -129,9 +129,9 @@ import { stripNonCanonicalTags } from './personas.js'
|
||||
function isSystemPromptEcho(content){
|
||||
if(!content) return false;
|
||||
var head = String(content).trim().slice(0, 240);
|
||||
if(/^(hello[!,\.]?\s+|hi[!,\.]?\s+|hey[!,\.]?\s+)?(i['’]?m|i am|you are)\s+((the\s+)?coding\s+agent|an autonomous|the agent|an? ai)/i.test(head)) return true;
|
||||
if(/^(hello[!,.]?\s+|hi[!,.]?\s+|hey[!,.]?\s+)?(i['’]?m|i am|you are)\s+((the\s+)?coding\s+agent|an autonomous|the agent|an? ai)/i.test(head)) return true;
|
||||
if(/^(i am|i['’]m)\s+ready\s+to\s+(receive|assist|help)/i.test(head)) return true;
|
||||
if(/^(hello|hi|hey)[!,\.]?\s+i['’]?m\s+ready/i.test(head)) return true;
|
||||
if(/^(hello|hi|hey)[!,.]?\s+i['’]?m\s+ready/i.test(head)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1500,7 +1500,6 @@ import {
|
||||
msgs.push(mkMsg('assistant', ''));
|
||||
|
||||
inp.value='';inp.style.height='auto';
|
||||
var sentImages = pendingImages.slice();
|
||||
pendingImages = [];
|
||||
renderAttachments();
|
||||
|
||||
|
||||
@@ -2237,6 +2237,25 @@ impl RemoteServer {
|
||||
pub fn tunnel_pid(&self) -> Option<u32> {
|
||||
self.tunnel_child.as_ref().map(|c| c.id())
|
||||
}
|
||||
|
||||
/// End the tunnel and empty its slot. The one entry point for "the app is
|
||||
/// going away", shared by `Drop` below and by
|
||||
/// `AppState::shutdown_subprocesses`, which is the path that actually runs
|
||||
/// on a quit.
|
||||
///
|
||||
/// `take()` and not a borrow, for the same reason every slot in
|
||||
/// `state.rs` takes: the two callers can BOTH fire on one quit (the
|
||||
/// explicit shutdown, then Tauri dropping the managed state if it happens
|
||||
/// to). A second pass must find nothing. It is not a harmless repeat —
|
||||
/// `kill_tree`'s snapshot includes the root, so the second call would
|
||||
/// SIGTERM/SIGKILL a pid that the kernel is free to have handed to a
|
||||
/// stranger in between, and on Windows `taskkill /T /F` would take that
|
||||
/// stranger's whole tree.
|
||||
pub fn shutdown_tunnel(&mut self) {
|
||||
if let Some(child) = self.tunnel_child.take() {
|
||||
kill_tunnel_child(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill and reap the tunnel process.
|
||||
@@ -2321,19 +2340,26 @@ fn kill_registered_tunnel(remote: &std::sync::Mutex<RemoteServer>, pid: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last line of defence for the public tunnel.
|
||||
/// Belt-and-suspenders for the public tunnel, exactly like `Drop for AppState`.
|
||||
///
|
||||
/// Tauri v2 does not reliably drop managed state on `app.exit(0)`, which is
|
||||
/// why `AppState` has an explicit shutdown path — but that path lives outside
|
||||
/// this module and does not reach the tunnel. On the quit paths where Drop
|
||||
/// DOES run, this closes the tunnel; on Windows the kill-on-close job object
|
||||
/// (`tie_child_to_app_lifetime`) covers the rest, and on Unix a hard kill is
|
||||
/// caught by the startup sweep in `start_remote_server`.
|
||||
/// This used to be the tunnel's ONLY quit path, and that was the gap: Tauri v2
|
||||
/// does not reliably drop managed state on `app.exit(0)`, which is why
|
||||
/// `AppState::shutdown_subprocesses` exists and why every other daemon —
|
||||
/// Ollama, ComfyUI, the bundled llama-server, the embeddings server, the MLX
|
||||
/// sidecar, the trainer — is listed there. The tunnel was not, so on the quit
|
||||
/// paths where Tauri skipped our destructors a `cloudflared` survived and kept
|
||||
/// publishing `*.trycloudflare.com` at 127.0.0.1:11435 — the port the NEXT
|
||||
/// launch binds, which hands the survivor the new session while the app's own
|
||||
/// indicator reads OFF. That is the core of T-39.
|
||||
///
|
||||
/// `shutdown_subprocesses` now takes the tunnel with it, so this Drop covers
|
||||
/// only the remaining "Tauri DID run our destructor" case and finds an empty
|
||||
/// slot whenever the explicit path got there first. On Windows the
|
||||
/// kill-on-close job object (`tie_child_to_app_lifetime`) covers a hard kill,
|
||||
/// and on Unix the startup sweep in `start_remote_server` is the second line.
|
||||
impl Drop for RemoteServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.tunnel_child.take() {
|
||||
kill_tunnel_child(child);
|
||||
}
|
||||
self.shutdown_tunnel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4251,6 +4277,38 @@ mod remote_hardening_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// KF-1, as a pure decision — no process, no signal, every platform.
|
||||
///
|
||||
/// The runtime proof lives in `state.rs` and needs a stand-in child; this
|
||||
/// one only asks whether the explicit quit path reaches for the tunnel
|
||||
/// slot at all, which is the whole of the bug: `shutdown_subprocesses`
|
||||
/// listed every other daemon and named the tunnel nowhere, so the tunnel
|
||||
/// depended on `Drop`, the very thing that method exists because Tauri
|
||||
/// does not reliably run. Cheap guard against a refactor quietly dropping
|
||||
/// the one line again — the same shape as trainer.rs's guard, for the same
|
||||
/// reason.
|
||||
#[test]
|
||||
fn the_explicit_quit_path_reaches_the_tunnel() {
|
||||
let state_rs = include_str!("../state.rs");
|
||||
let shutdown = &state_rs[state_rs
|
||||
.find("pub fn shutdown_subprocesses")
|
||||
.expect("shutdown_subprocesses is gone")..];
|
||||
let shutdown = &shutdown[..shutdown
|
||||
.find("\nimpl Drop for AppState")
|
||||
.expect("the end of the shutdown impl block")];
|
||||
assert!(
|
||||
shutdown.contains("shutdown_tunnel()"),
|
||||
"shutdown_subprocesses no longer kills the cloudflared tunnel (KF-1)",
|
||||
);
|
||||
// ...and it must take the slot, not borrow it: `Drop for AppState`
|
||||
// calls this method a second time and the managed `RemoteServer`'s own
|
||||
// Drop may follow, at a pid that may have been recycled.
|
||||
assert!(
|
||||
production_code().contains("self.tunnel_child.take()"),
|
||||
"the tunnel is no longer taken out of its slot, so a second pass re-kills its pid",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_server_without_a_tunnel_is_a_no_op() {
|
||||
drop(RemoteServer::new());
|
||||
|
||||
+128
-1
@@ -449,7 +449,8 @@ const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
impl AppState {
|
||||
/// Kill every subprocess we spawned (ComfyUI, Ollama, Claude Code, Whisper).
|
||||
/// Kill every subprocess we spawned (the Cloudflare tunnel, ComfyUI,
|
||||
/// Ollama, Claude Code, Whisper).
|
||||
///
|
||||
/// Live testing on 2026-05-25 showed that Tauri v2's `app.exit(0)` returns
|
||||
/// from the run loop on Windows WITHOUT actually dropping the managed
|
||||
@@ -460,6 +461,42 @@ impl AppState {
|
||||
/// from every quit path instead so kj103x's Ollama-orphan stays fixed even
|
||||
/// when Tauri's destructor chain skips us.
|
||||
pub fn shutdown_subprocesses(&self) {
|
||||
// The Cloudflare quick tunnel — FIRST, and it is the only entry here
|
||||
// whose position in the list is an argument rather than an accident.
|
||||
//
|
||||
// Why it belongs here at all: this method exists because Tauri v2 may
|
||||
// skip our destructors, and every other daemon was moved into it for
|
||||
// that reason. The tunnel was left hanging on `Drop for RemoteServer`
|
||||
// alone, i.e. on precisely the mechanism this method exists to work
|
||||
// around. A survivor is not a resource leak like a stray Ollama — it
|
||||
// keeps a public `*.trycloudflare.com` address pointed at
|
||||
// 127.0.0.1:11435, and the next launch binds that same port, so the
|
||||
// stranger's tunnel silently serves the new session while
|
||||
// `tunnel_status` reports the tunnel as off (T-39).
|
||||
//
|
||||
// Why first: while the tunnel is up, the internet still reaches the
|
||||
// remote server on 11435, which proxies through to Ollama and ComfyUI
|
||||
// — the daemons the rest of this method is in the middle of killing.
|
||||
// Closing the door before emptying the rooms behind it costs nothing
|
||||
// (nothing below depends on the tunnel), and last would hold the door
|
||||
// open across every blocking call in here: the `taskkill ... .output()`
|
||||
// waits on Windows and the `lsof` shell-out for the MLX port on macOS.
|
||||
//
|
||||
// `shutdown_tunnel` take()s the slot, like every branch below, so the
|
||||
// Drop pass that may still follow has nothing left to fire at; and it
|
||||
// goes through `kill_tree`, so a cloudflared that spawned children
|
||||
// leaves no orphans. A poisoned lock is recovered rather than skipped
|
||||
// — unlike every other slot here, silently leaving this one alive is a
|
||||
// security event, and taking an `Option<Child>` out of the struct
|
||||
// cannot observe a broken invariant.
|
||||
{
|
||||
let mut remote = self
|
||||
.remote
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
remote.shutdown_tunnel();
|
||||
}
|
||||
|
||||
if let Ok(mut proc) = self.ollama_process.lock() {
|
||||
// take(), not a borrow: leaving the pid in the slot let the Drop
|
||||
// pass below fire a second taskkill at it.
|
||||
@@ -660,6 +697,96 @@ mod shutdown_tests {
|
||||
assert!(state.trainer_process.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A pid can outlive the SIGTERM by a scheduler tick; polling is the
|
||||
/// honest form of "it died", asserting straight away is a race.
|
||||
fn dies_within(pid: u32, budget: std::time::Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !alive(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
!alive(pid)
|
||||
}
|
||||
|
||||
/// A stand-in for the cloudflared quick tunnel, parked in the slot the
|
||||
/// real one lives in.
|
||||
fn park_a_tunnel(state: &AppState) -> u32 {
|
||||
let child = sleeper();
|
||||
let pid = child.id();
|
||||
state.remote.lock().unwrap().tunnel_child = Some(child);
|
||||
assert!(alive(pid), "the stand-in tunnel was not running to begin with");
|
||||
pid
|
||||
}
|
||||
|
||||
/// KF-1. Every other daemon is killed from this method because Tauri v2
|
||||
/// may skip `Drop`; the tunnel was left hanging on `Drop for RemoteServer`
|
||||
/// alone, i.e. on exactly the mechanism this method exists to replace. A
|
||||
/// survivor keeps a public *.trycloudflare.com address pointed at
|
||||
/// 127.0.0.1:11435, and the next launch binds that port (T-39).
|
||||
#[test]
|
||||
fn shutdown_takes_the_tunnel_with_it() {
|
||||
let state = AppState::new();
|
||||
let pid = park_a_tunnel(&state);
|
||||
|
||||
state.shutdown_subprocesses();
|
||||
|
||||
assert!(
|
||||
dies_within(pid, std::time::Duration::from_secs(5)),
|
||||
"the TUNNEL survived the explicit quit path (KF-1)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Separate property, separate test: the slot has to be EMPTY afterwards.
|
||||
/// `Drop for AppState` runs `shutdown_subprocesses` again and the managed
|
||||
/// state's own `Drop for RemoteServer` may follow it — a second kill would
|
||||
/// go through `kill_tree`, whose snapshot includes the root, at a pid the
|
||||
/// kernel is free to have recycled by then.
|
||||
#[test]
|
||||
fn shutdown_empties_the_tunnel_slot_so_a_second_pass_finds_nothing() {
|
||||
let state = AppState::new();
|
||||
let pid = park_a_tunnel(&state);
|
||||
|
||||
state.shutdown_subprocesses();
|
||||
|
||||
assert!(
|
||||
state.remote.lock().unwrap().tunnel_pid().is_none(),
|
||||
"the tunnel slot still holds pid {pid} — the Drop pass will kill it a second time",
|
||||
);
|
||||
}
|
||||
|
||||
/// Third property: the kill is a TREE kill. `cloudflared` is spawned
|
||||
/// through `spawn_piped` and may have children of its own; a plain
|
||||
/// `Child::kill` would reap the root and leave them adopted by init, which
|
||||
/// is the same orphan one level down.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn shutdown_takes_the_tunnels_children_with_it() {
|
||||
let state = AppState::new();
|
||||
|
||||
let mut cmd = std::process::Command::new(crate::test_support::posix_shell());
|
||||
cmd.arg("-c").arg("sleep 30 & sleep 30");
|
||||
let child = crate::process_util::spawn_piped(cmd).expect("spawn a tunnel stand-in");
|
||||
let pid = child.id();
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
|
||||
let mut sys = sysinfo::System::new();
|
||||
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
let kids = crate::commands::shell::descendants(pid, &sys);
|
||||
assert!(!kids.is_empty(), "the stand-in spawned nothing — test setup is wrong");
|
||||
|
||||
state.remote.lock().unwrap().tunnel_child = Some(child);
|
||||
state.shutdown_subprocesses();
|
||||
|
||||
for p in kids.iter().copied().chain(std::iter::once(pid)) {
|
||||
assert!(
|
||||
dies_within(p, std::time::Duration::from_secs(5)),
|
||||
"{p} survived the quit path — the tunnel was killed, not its tree",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quitting with nothing running must not panic or block.
|
||||
#[test]
|
||||
fn shutdown_on_an_idle_state_is_a_no_op() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Der Satzzeichen-Teil des mobilen Echo-Wächters, festgenagelt.
|
||||
*
|
||||
* ── Warum diese Datei existiert ──
|
||||
*
|
||||
* `isSystemPromptEcho` (mobile-client/agent-core.js) filtert die Zeile
|
||||
* „Hello, I am the Coding Agent…", die kleinere Modelle nach einem
|
||||
* Tool-Fehler statt einer Antwort schicken. Die Öffnung darf von einem von
|
||||
* drei Satzzeichen gefolgt sein: `!`, `,` oder `.` — als Zeichenklasse
|
||||
* `[!,.]`.
|
||||
*
|
||||
* Der Punkt stand dort bis zum 01.09.2026 als `\.` geschrieben, escaped.
|
||||
* Innerhalb einer Zeichenklasse ist das ein IdentityEscape und damit wirkungs-
|
||||
* los; eslint meldete es als `no-useless-escape`, und der Backslash ist jetzt
|
||||
* weg. Die Bytes dieser Datei SIND die ausgelieferte Seite, also ist jede
|
||||
* Änderung daran eine Änderung an dem, was auf dem Telefon läuft — und der
|
||||
* Rust-Test `mobile_landing_is_what_the_sources_say` vergleicht die Seite nur
|
||||
* mit sich selbst, er kann eine Verhaltensänderung nicht sehen.
|
||||
*
|
||||
* Diese Datei schließt genau diese Lücke: nicht „der Backslash ist weg",
|
||||
* sondern „die Klasse akzeptiert weiterhin alle drei Zeichen und nichts
|
||||
* sonst". Wer den Punkt beim nächsten Mal wirklich aus der Klasse entfernt,
|
||||
* läuft hier auf.
|
||||
*
|
||||
* `mobile-codex-parity.test.ts` prüft denselben Wächter, aber nur mit `!` und
|
||||
* `,` in den Beispielsätzen; der Punkt kam dort nie vor.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isSystemPromptEcho } from '../../../mobile-client/agent-core.js'
|
||||
|
||||
describe('Der mobile Echo-Wächter und seine Satzzeichen', () => {
|
||||
// Beide Regexe mit einer Zeichenklasse: die Begrüßungs-Alternative in der
|
||||
// ersten Zeile und die „…ready"-Zeile in der dritten.
|
||||
const OPENERS = ['Hello', 'Hi', 'Hey']
|
||||
|
||||
it('erkennt die Begrüßung mit jedem der drei Satzzeichen — Punkt eingeschlossen', () => {
|
||||
for (const opener of OPENERS) {
|
||||
for (const punct of ['!', ',', '.']) {
|
||||
const echo = `${opener}${punct} I am the coding agent`
|
||||
expect(isSystemPromptEcho(echo), echo).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('erkennt die ready-Zeile mit jedem der drei Satzzeichen — Punkt eingeschlossen', () => {
|
||||
for (const opener of OPENERS) {
|
||||
for (const punct of ['!', ',', '.']) {
|
||||
const echo = `${opener}${punct} I'm ready`
|
||||
expect(isSystemPromptEcho(echo), echo).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('das Satzzeichen bleibt optional', () => {
|
||||
expect(isSystemPromptEcho('Hello I am the coding agent')).toBe(true)
|
||||
expect(isSystemPromptEcho("Hey I'm ready")).toBe(true)
|
||||
})
|
||||
|
||||
it('die Klasse ist auf diese drei Zeichen begrenzt und frisst kein viertes', () => {
|
||||
// Die Gegenrichtung. Ohne sie wäre die Klasse auch dann grün, wenn jemand
|
||||
// sie zu `[\s\S]` verbreitert — ein Wächter, der jede Begrüßung schluckt,
|
||||
// verschluckt auch echte Antworten des Modells.
|
||||
for (const punct of [';', ':', '?', '-', '*', 'x']) {
|
||||
const notAnEcho = `Hello${punct} I am the coding agent`
|
||||
expect(isSystemPromptEcho(notAnEcho), notAnEcho).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('eine echte Antwort, die mit derselben Silbe anfängt, bleibt unangetastet', () => {
|
||||
expect(isSystemPromptEcho('Hello. The bug is in src/main.ts, line 40.')).toBe(false)
|
||||
expect(isSystemPromptEcho('Hi. Done — three files written.')).toBe(false)
|
||||
})
|
||||
})
|
||||
+25
-11
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* ── What already cleans up, and what does not ────────────────────────────
|
||||
*
|
||||
* `AppState::shutdown_subprocesses()` (`src-tauri/src/state.rs:462`) kills the
|
||||
* children RUST spawned and tracks in AppState: Ollama, ComfyUI, the bundled
|
||||
* llama-server and embeddings server, the MLX video subprocess and image
|
||||
* sidecar, the LoRA trainer, the installer child trees, whisper. External MCP
|
||||
* `AppState::shutdown_subprocesses()` (`src-tauri/src/state.rs`) kills the
|
||||
* children RUST spawned and tracks in AppState: the Cloudflare quick tunnel,
|
||||
* Ollama, ComfyUI, the bundled llama-server and embeddings server, the MLX
|
||||
* video subprocess and image sidecar, the LoRA trainer, the installer child
|
||||
* trees, whisper. External MCP
|
||||
* servers are none of those — they are spawned from the frontend through
|
||||
* `@tauri-apps/plugin-shell`, land in the PLUGIN's child map, and never appear
|
||||
* in AppState. So this file does not duplicate that function; the two have an
|
||||
@@ -48,13 +49,26 @@
|
||||
*
|
||||
* ── What this cannot promise ─────────────────────────────────────────────
|
||||
*
|
||||
* `child.kill()` is an async IPC call. An unload handler cannot await, so the
|
||||
* request is posted and the page goes away; the Rust side normally receives it
|
||||
* because the post is synchronous, but nothing here can prove the process was
|
||||
* reaped. And the plugin's kill is a kill of the DIRECT child only — for
|
||||
* `npx -y some-mcp-server` (or `npx.cmd` on Windows) that is the launcher, and
|
||||
* the node process it forked is not in any tree walk. Closing that needs a
|
||||
* `kill_tree` on the Rust side, which is out of scope here.
|
||||
* The kill is an async IPC call. An unload handler cannot await, so the request
|
||||
* is posted and the page goes away; the Rust side normally receives it because
|
||||
* the post is synchronous, but nothing here can prove the process was reaped.
|
||||
*
|
||||
* The tree kill this paragraph used to call out of reach is not: `c5773322`
|
||||
* added `process_util::kill_pid_tree` and `4c7bf748` put a `kill_process_tree`
|
||||
* command in front of it, guarded by `may_kill_pid` so only this app's own
|
||||
* subtree can be addressed. `external-client.ts` calls it in
|
||||
* `killServerProcess` INSTEAD of the plugin's `child.kill()` — never after,
|
||||
* because the direct child's death reparents its children to init and the
|
||||
* guard then (correctly) refuses. So the `npx -y some-mcp-server` case, where
|
||||
* the launcher is the direct child and the real `node` server is a grandchild,
|
||||
* is covered on the path this file drives.
|
||||
*
|
||||
* What remains unpromised is the fallback: outside Tauri, against a Rust side
|
||||
* older than `c5773322`, or when the guard refuses because the process already
|
||||
* exited on its own, `external-client.ts` falls back to the plugin's kill of
|
||||
* the DIRECT child and logs that it did. That kill is one signal to one
|
||||
* process (`tauri-plugin-shell` 2.3.5, `src/process/mod.rs:78`), so a server
|
||||
* that forked its own processes can still leave them behind there.
|
||||
*/
|
||||
import { disconnectAllExternalServers } from './external-client'
|
||||
|
||||
|
||||
Reference in new issue
Block a user