Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ sha2 = "0.10"
thiserror = "1"
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] }
dirs = "5"
libc = "0.2"

[profile.release]
panic = "abort"
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ use commands::{
};
use snapshots::session_snapshots;
use tauri::Manager;
use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write};
use tools::{
tool_bash, tool_bash_cancel, tool_edit, tool_glob, tool_grep, tool_read, tool_write, BashState,
};
use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand All @@ -37,6 +39,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(VoiceState::default())
.manage(BashState::default())
.invoke_handler(tauri::generate_handler![
get_app_info,
read_credentials,
Expand All @@ -62,6 +65,7 @@ pub fn run() {
tool_write,
tool_edit,
tool_bash,
tool_bash_cancel,
tool_glob,
tool_grep,
session_snapshots,
Expand Down
166 changes: 152 additions & 14 deletions apps/desktop/src-tauri/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@

use crate::snapshots;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::sync::{oneshot, Mutex};

// ──────────────────────────────────────────────────────────────────────────
// Snapshot capture
Expand Down Expand Up @@ -240,19 +242,67 @@ pub struct BashOk {
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
pub cancelled: bool,
}

#[derive(Default)]
pub struct BashState {
// `Some(sender)` is running; `None` records an abort that raced ahead of
// command registration so the process never escapes cancellation.
active: Mutex<HashMap<String, Option<oneshot::Sender<()>>>>,
}

#[cfg(unix)]
fn kill_process_group(pid: u32) {
// The shell is placed in its own process group below, so a negative PID
// terminates the shell and every descendant it spawned.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}

#[cfg(not(unix))]
fn kill_process_group(_pid: u32) {}

#[tauri::command]
pub async fn tool_bash(input: BashInput) -> Result<BashOk, String> {
pub async fn tool_bash(
input: BashInput,
command_id: String,
state: tauri::State<'_, BashState>,
) -> Result<BashOk, String> {
run_bash(input, command_id, &state).await
}

async fn run_bash(
input: BashInput,
command_id: String,
state: &BashState,
) -> Result<BashOk, String> {
let timeout = std::time::Duration::from_millis(input.timeout_ms.unwrap_or(120_000));
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(&input.command);
if let Some(cwd) = input.cwd.as_ref() {
cmd.current_dir(cwd);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.as_std_mut().process_group(0);
}

let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
let pid = child.id().ok_or("spawned process has no pid")?;
let (cancel_tx, mut cancel_rx) = oneshot::channel();
{
let mut active = state.active.lock().await;
if matches!(active.get(&command_id), Some(None)) {
active.remove(&command_id);
drop(cancel_tx);
} else {
active.insert(command_id.clone(), Some(cancel_tx));
}
}
let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?;
let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?;

Expand All @@ -268,31 +318,77 @@ pub async fn tool_bash(input: BashInput) -> Result<BashOk, String> {
s
});

let mut timed_out = false;
let exit_status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(s) => s.map_err(|e| format!("wait: {e}"))?,
Err(_) => {
timed_out = true;
enum Finish {
Exited(std::io::Result<std::process::ExitStatus>),
TimedOut,
Cancelled,
}
let finish = tokio::select! {
status = child.wait() => Finish::Exited(status),
_ = tokio::time::sleep(timeout) => Finish::TimedOut,
_ = &mut cancel_rx => Finish::Cancelled,
};
state.active.lock().await.remove(&command_id);

let (exit_code, timed_out, cancelled) = match finish {
Finish::Exited(status) => (
status
.map_err(|e| format!("wait: {e}"))?
.code()
.unwrap_or(-1),
false,
false,
),
Finish::TimedOut => {
kill_process_group(pid);
let _ = child.start_kill();
let _ = child.wait().await;
(124, true, false)
}
Finish::Cancelled => {
kill_process_group(pid);
let _ = child.start_kill();
let _ = child.wait().await;
return Ok(BashOk {
stdout: String::new(),
stderr: format!("timeout after {}ms", timeout.as_millis()),
exit_code: 124,
timed_out,
});
(130, false, true)
}
};
let stdout = stdout_task.await.unwrap_or_default();
let stderr = stderr_task.await.unwrap_or_default();
let mut stderr = stderr_task.await.unwrap_or_default();
if timed_out {
stderr.push_str(&format!("\ntimeout after {}ms", timeout.as_millis()));
}
if cancelled {
stderr.push_str("\naborted by user");
}
Ok(BashOk {
stdout,
stderr,
exit_code: exit_status.code().unwrap_or(-1),
exit_code,
timed_out,
cancelled,
})
}

#[tauri::command]
pub async fn tool_bash_cancel(
command_id: String,
state: tauri::State<'_, BashState>,
) -> Result<bool, String> {
Ok(cancel_bash(command_id, &state).await)
}

async fn cancel_bash(command_id: String, state: &BashState) -> bool {
let mut active = state.active.lock().await;
match active.remove(&command_id) {
Some(Some(cancel)) => cancel.send(()).is_ok(),
Some(None) => true,
None => {
active.insert(command_id, None);
true
}
}
}

// ──────────────────────────────────────────────────────────────────────────
// Glob (filesystem pattern match)
// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -454,17 +550,59 @@ mod casing_tests {
stderr: String::new(),
exit_code: 0,
timed_out: false,
cancelled: false,
})
.unwrap();
let k = keys(&v);
// The exit-code badge bug: renderer compares r.exitCode !== 0.
assert!(k.contains(&"exitCode".to_string()), "got {k:?}");
assert!(k.contains(&"timedOut".to_string()), "got {k:?}");
assert!(k.contains(&"cancelled".to_string()), "got {k:?}");
assert!(
!k.contains(&"exit_code".to_string()),
"snake_case leaked: {k:?}"
);
}

#[cfg(unix)]
#[tokio::test]
async fn bash_cancel_kills_descendants() {
use std::sync::Arc;

let root = std::env::temp_dir().join(format!(
"dc-rust-bash-cancel-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
let marker = root.join("orphan-marker.txt");
let command = format!("(sleep 0.4; echo orphan > '{}') & wait", marker.display());
let state = Arc::new(BashState::default());
let run_state = state.clone();
let task = tokio::spawn(async move {
run_bash(
BashInput {
command,
cwd: Some(root.to_string_lossy().to_string()),
timeout_ms: Some(5_000),
},
"cancel-test".to_string(),
&run_state,
)
.await
});

tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(cancel_bash("cancel-test".to_string(), &state).await);
let result = task.await.unwrap().unwrap();
assert!(result.cancelled);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(!marker.exists(), "descendant survived cancellation");
let _ = std::fs::remove_dir_all(marker.parent().unwrap());
}
}

// ── snapshot capture path ───────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/mac-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise<StartTurnResu
temperature: effortParams.temperature,
cwd: args.cwd ?? '/',
signal: abort.signal,
mode: args.mode,
mode: args.mode ?? 'default',
// Disable system reminders in the renderer — they require node:fs
// (reads todos.json + stats files). The Mac UI surfaces those
// contextually elsewhere.
Expand Down
42 changes: 32 additions & 10 deletions apps/desktop/src/lib/mac-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { invoke } from '@tauri-apps/api/core';
import type { ToolHandler, ToolResult } from '@deepcode/core/dist/types.js';
import { getActiveSessionId } from './mac-session.js';

let bashCommandSeq = 0;

/**
* Tolerant key pick — accepts either snake_case or camelCase. DeepSeek
* occasionally normalizes JSON Schema keys to camelCase regardless of
Expand Down Expand Up @@ -218,24 +220,44 @@ export const MacBashTool: ToolHandler = {
required: ['command'],
},
},
async execute(input: Record<string, unknown>): Promise<ToolResult> {
async execute(input: Record<string, unknown>, ctx): Promise<ToolResult> {
try {
const command = pickStr(input, 'command', 'cmd');
if (!command) {
return { content: 'Error: missing command', isError: true };
}
const r = (await invoke('tool_bash', {
input: {
command,
cwd: pickStr(input, 'cwd', 'working_dir'),
timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'),
},
})) as { stdout: string; stderr: string; exitCode: number; timedOut: boolean };
if (ctx.signal?.aborted) {
return { content: 'aborted by user', isError: true };
}
const commandId = `bash-${Date.now().toString(36)}-${bashCommandSeq++}`;
const onAbort = (): void => {
void invoke('tool_bash_cancel', { commandId });
};
ctx.signal?.addEventListener('abort', onAbort, { once: true });
let r: {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
cancelled: boolean;
};
try {
r = (await invoke('tool_bash', {
commandId,
input: {
command,
cwd: pickStr(input, 'cwd', 'working_dir'),
timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'),
},
})) as typeof r;
} finally {
ctx.signal?.removeEventListener('abort', onAbort);
}
const combined = (r.stdout || '') + (r.stderr ? `\n[stderr]\n${r.stderr}` : '');
return {
content: combined || `(no output, exit ${r.exitCode})`,
data: { exitCode: r.exitCode, timedOut: r.timedOut },
isError: r.exitCode !== 0,
data: { exitCode: r.exitCode, timedOut: r.timedOut, cancelled: r.cancelled },
isError: r.exitCode !== 0 || r.cancelled,
};
} catch (err) {
return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true };
Expand Down
22 changes: 21 additions & 1 deletion apps/lsp/src/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { handleMessage, type LspMessage } from './handler.js';
import { __test, handleMessage, type LspMessage } from './handler.js';

describe('handleMessage — initialize', () => {
it('returns capabilities + serverInfo + supported commands', async () => {
Expand Down Expand Up @@ -102,6 +102,26 @@ describe('handleMessage — executeCommand', () => {
expect((out[0]!.result as { aborted: boolean }).aborted).toBe(false);
});

it('deepcode.abort aborts the active turn controller', async () => {
const controller = new AbortController();
__test.state.activeTurns.set('active-turn', controller);
const out: LspMessage[] = [];

await handleMessage(
{
jsonrpc: '2.0',
id: 41,
method: 'workspace/executeCommand',
params: { command: 'deepcode.abort', arguments: [{ turnId: 'active-turn' }] },
},
(m) => out.push(m),
);

expect((out[0]!.result as { aborted: boolean }).aborted).toBe(true);
expect(controller.signal.aborted).toBe(true);
__test.state.activeTurns.delete('active-turn');
});

it('errors on unknown command', async () => {
const out: LspMessage[] = [];
await handleMessage(
Expand Down
Loading
Loading