fix(backend): show the listener before ending it, never a blind kill

Greptile, security, on the reclaim guidance: the convenient one-liner does not
preserve the identity port_holder established.

  - `lsof -ti tcp:3900` matches CONNECTED CLIENTS as well as the listener, so
    piping it into kill can end a process that merely talks to VoiceStudio.
  - Windows `findstr :3900` matches `:39001` and established connections too.

And the identity itself is a fact about the moment the message was written. By
the time a user runs a command it has to be re-established, and only they can
do that.

So both platforms now get two steps: a lookup restricted to the LISTENING
socket that prints the pid and process name, and a kill of that pid once the
user has confirmed what it is. A test pins that the guidance never pipes a
lookup into kill and always shows something to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
This commit is contained in:
Palash Debnath
2026-09-10 00:08:22 -07:00
co-authored by Claude Opus 5
parent 2afea02c94
commit 7610a0d86e
2 changed files with 52 additions and 9 deletions
+50 -7
View File
@@ -455,16 +455,33 @@ pub fn port_holder(port: u16) -> PortHolder {
}
}
/// The command that ends a listener on `port`, for the platform this build
/// runs on. Offered only when the holder identified itself as our own backend
/// — never for a listener we could not identify.
/// How to find and end the listener on `port`, for the platform this build
/// runs on. Offered only when the holder identified itself as our own backend.
///
/// Two steps, deliberately, and never a one-liner that pipes a lookup straight
/// into `kill`. `lsof -ti tcp:PORT` matches *connected clients* as well as the
/// listener, and Windows `findstr :3900` matches `:39001` and established
/// connections too — so the convenient one-liner can end a process that merely
/// talks to VoiceStudio, or one that has nothing to do with it. The identity
/// `port_holder` established is a fact about the moment the message was
/// written; by the time the user runs a command it has to be re-established,
/// and only they can do that. So the first command shows exactly one listening
/// process to look at, and the second ends that pid.
fn reclaim_command(port: u16) -> String {
if cfg!(target_os = "windows") {
format!(
"netstat -ano | findstr :{port} (then: taskkill /PID <the last column> /F)"
"Get-NetTCPConnection -LocalPort {port} -State Listen | \
Select-Object OwningProcess, @{{n='Name';e={{(Get-Process -Id \
$_.OwningProcess).ProcessName}}}}\n\n \
...then, once you have confirmed it is python or omnivoice:\n\n \
Stop-Process -Id <OwningProcess>"
)
} else {
format!("lsof -ti tcp:{port} | xargs kill")
format!(
"lsof -nP -iTCP:{port} -sTCP:LISTEN\n\n \
...then, once you have confirmed the COMMAND is python or \
omnivoice:\n\n kill <PID>"
)
}
}
@@ -491,7 +508,7 @@ pub fn port_conflict_message(port: u16, holder: &PortHolder, suffix: &str) -> St
format!(
"Port {port} is in use by a VoiceStudio backend from version \
{version}, left running by an earlier install. This build is \
{}, so it cannot use that one. End it from a terminal:\n\n {}",
{}, so it cannot use that one. Find and end it from a terminal:\n\n {}",
env!("CARGO_PKG_VERSION"),
reclaim_command(port)
)
@@ -1657,10 +1674,36 @@ mod tests {
let msg = port_conflict_message(3900, &holder, "");
assert!(msg.contains("another application"), "{msg}");
assert!(!msg.contains("lsof"), "{msg}");
assert!(!msg.contains("taskkill"), "{msg}");
assert!(!msg.contains("Get-NetTCPConnection"), "{msg}");
assert!(!msg.contains("kill"), "{msg}");
}
}
#[test]
fn the_reclaim_guidance_never_pipes_a_lookup_into_kill() {
// Greptile, security: the convenient one-liner does not preserve the
// identity `port_holder` established. `lsof -ti tcp:3900 | xargs kill`
// matches CONNECTED CLIENTS as well as the listener, and Windows
// `findstr :3900` matches `:39001` and established connections — so a
// user following it can end a process that merely talks to
// VoiceStudio, or one unrelated to it. The lookup has to be shown for
// a human to check before anything is signalled.
let guidance = reclaim_command(3900);
assert!(
!guidance.contains("| xargs kill") && !guidance.contains("|xargs kill"),
"a lookup piped straight into kill can end a process nobody identified: {guidance}"
);
// The listener, not every socket on the port.
if cfg!(target_os = "windows") {
assert!(guidance.contains("-State Listen"), "{guidance}");
} else {
assert!(guidance.contains("-sTCP:LISTEN"), "{guidance}");
}
// And a step where the user confirms what they found.
assert!(guidance.contains("confirmed"), "{guidance}");
}
#[test]
fn every_wording_still_triggers_the_localised_port_hint() {
// detectHints matches /port.*in use/i to swap this English string for
+2 -2
View File
@@ -97,13 +97,13 @@ describe('the Rust failure messages reach the localised hint (#1223, #1933)', ()
'our own orphan',
'Port 3900 is in use by a VoiceStudio backend from an earlier session that never shut ' +
'down. It has no window to quit, so closing VoiceStudio will not release it. End it ' +
'from a terminal:\n\n lsof -ti tcp:3900 | xargs kill',
'Find and end it from a terminal:\n\n lsof -nP -iTCP:3900 -sTCP:LISTEN',
],
[
'a backend from another version',
'Port 3900 is in use by a VoiceStudio backend from version 0.1.0, left running by an ' +
'earlier install. This build is 0.5.2, so it cannot use that one. End it from a ' +
'terminal:\n\n lsof -ti tcp:3900 | xargs kill',
'terminal:\n\n lsof -nP -iTCP:3900 -sTCP:LISTEN',
],
[
'an unidentified listener',