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:
David committed 2026-09-01 08:10:58 -07:00
1 parent 4bb2159070
commit 067a3c2baf
6 files changed
+296 -25

No files matched your search

+2 -2
View File
@@ -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;
}
-1
View File
@@ -1500,7 +1500,6 @@ import {
msgs.push(mkMsg('assistant', ''));
inp.value='';inp.style.height='auto';
var sentImages = pendingImages.slice();
pendingImages = [];
renderAttachments();
+68 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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'