Skip to content
Closed
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
3 changes: 3 additions & 0 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ pub struct AppState {
/// Cancellation flags for active file transfers (download/upload), keyed by transfer_id.
pub active_transfers: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
pub announcement_scheduler: Arc<announcement::AnnouncementScheduler>,
pub is_primary_instance: bool,
}

impl AppState {
pub async fn new_async(
token_usage_service: Arc<token_usage::TokenUsageService>,
is_primary_instance: bool,
) -> BitFunResult<Self> {
let start_time = std::time::Instant::now();

Expand Down Expand Up @@ -311,6 +313,7 @@ impl AppState {
active_searches: Arc::new(Mutex::new(HashMap::new())),
active_transfers: Arc::new(Mutex::new(HashMap::new())),
announcement_scheduler,
is_primary_instance,
};

if let Some(workspace_info) = initial_workspace {
Expand Down
8 changes: 5 additions & 3 deletions src/apps/desktop/src/api/system_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,9 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> {
pub async fn minimize_to_tray(
app: tauri::AppHandle,
startup_trace: State<'_, DesktopStartupTrace>,
state: State<'_, AppState>,
) -> Result<(), String> {
if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) {
if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) {
log::warn!("Failed to initialize tray before minimizing: {}", error);
}
if let Some(window) = app.get_webview_window("main") {
Expand All @@ -461,8 +462,9 @@ pub async fn minimize_to_tray(
pub async fn initialize_tray_after_startup(
app: tauri::AppHandle,
startup_trace: State<'_, DesktopStartupTrace>,
state: State<'_, AppState>,
) -> Result<(), String> {
crate::tray::setup_tray(&app, &startup_trace).map_err(|e| e.to_string())
crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance).map_err(|e| e.to_string())
}

/// Minimal startup-window controls used by the static pre-React splash.
Expand Down Expand Up @@ -508,7 +510,7 @@ pub async fn startup_window_control(
crate::perform_process_exit_cleanup();
app.exit(0);
} else {
if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) {
if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) {
log::warn!("Failed to initialize tray before startup close: {}", error);
}
window.hide().map_err(|error| {
Expand Down
4 changes: 3 additions & 1 deletion src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod computer_use;
pub mod crash_diagnostics;
pub mod logging;
pub mod macos_menubar;
pub mod single_instance;
pub mod startup_trace;
pub mod theme;
pub mod tray;
Expand Down Expand Up @@ -209,6 +210,7 @@ fn get_startup_native_trace(
/// Tauri application entry point
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run() {
let is_primary = single_instance::is_primary_instance();
let startup_started = Instant::now();
let startup_trace_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down Expand Up @@ -320,7 +322,7 @@ pub async fn run() {
);

let step_started = Instant::now();
let app_state = match AppState::new_async(token_usage_service).await {
let app_state = match AppState::new_async(token_usage_service, is_primary).await {
Ok(state) => state,
Err(e) => {
log::error!("Failed to initialize AppState: {}", e);
Expand Down
90 changes: 90 additions & 0 deletions src/apps/desktop/src/single_instance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Per-process single-instance detection via OS primitives.
//!
//! On Windows a named kernel mutex ensures at most one process holds the
//! "primary" role. On other platforms the function always returns `true`
//! because the system tray icon duplication issue is Windows-specific.

#[cfg(target_os = "windows")]
mod imp {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::sync::OnceLock;

extern "system" {
fn CreateMutexW(
lp_mutex_attributes: *const std::ffi::c_void,
b_initial_owner: i32,
lp_name: *const u16,
) -> isize;
fn GetLastError() -> u32;
}

const ERROR_ALREADY_EXISTS: u32 = 183;

/// The handle is kept alive for the lifetime of the primary process so the
/// kernel mutex is not destroyed prematurely. Non-primary instances
/// intentionally leak their handle — it is harmless and avoids a separate
/// `CloseHandle` extern declaration (which would clash with the existing
/// `windows`‑crate declaration in the same build graph).
static PRIMARY_MUTEX: OnceLock<isize> = OnceLock::new();

pub(crate) fn is_primary_instance_impl() -> bool {
#[cfg(not(test))]
let name: Vec<u16> = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex")
.encode_wide()
.chain(std::iter::once(0))
.collect();
#[cfg(test)]
let name: Vec<u16> = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex_Test")
.encode_wide()
.chain(std::iter::once(0))
.collect();

let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) };
if handle == 0 {
// If we cannot create the mutex, conservatively claim we're the
// primary instance so the tray icon is not silently lost.
return true;
}

let is_primary = unsafe { GetLastError() } != ERROR_ALREADY_EXISTS;

if is_primary {
PRIMARY_MUTEX.set(handle).ok();
}
// Non-primary: intentionally leak the duplicate handle — the kernel
// object is still owned by the primary process and the leaked handle
// is reclaimed by the OS when this process exits.

is_primary
}
}

#[cfg(not(target_os = "windows"))]
mod imp {
pub(crate) fn is_primary_instance_impl() -> bool {
true
}
}

pub(crate) fn is_primary_instance() -> bool {
imp::is_primary_instance_impl()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn first_call_primary_second_not() {
// The first call in a process should always report primary, and a
// second call must not because the mutex is already held. Verifying
// both assertions in the same test avoids the ordering dependency
// that would exist between two separate tests sharing the mutex.
assert!(is_primary_instance(), "first call must report primary");
assert!(
!is_primary_instance(),
"second call must not report primary"
);
}
}
26 changes: 26 additions & 0 deletions src/apps/desktop/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ static TRAY_ICON: OnceLock<tauri::tray::TrayIcon> = OnceLock::new();
static TRAY_SETUP_LOCK: Mutex<()> = Mutex::new(());
const TRAY_TRACE_CATEGORY: &str = "native_background";

pub(crate) fn should_create_tray(is_primary: bool) -> bool {
is_primary && TRAY_ICON.get().is_none()
}

struct TrayStrings {
show_app: &'static str,
quit_app: &'static str,
Expand Down Expand Up @@ -171,7 +175,12 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> {
pub fn setup_tray(
app: &tauri::AppHandle,
startup_trace: &DesktopStartupTrace,
is_primary: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if !should_create_tray(is_primary) {
return Ok(());
}

if TRAY_ICON.get().is_some() {
return Ok(());
}
Expand Down Expand Up @@ -299,3 +308,20 @@ fn toggle_main_window(app: &tauri::AppHandle) {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn should_create_tray_for_primary_without_existing_icon() {
// Before any tray is created, a primary instance should create one.
assert!(should_create_tray(true));
}

#[test]
fn should_not_create_tray_for_non_primary() {
// A non-primary instance should never create a tray icon.
assert!(!should_create_tray(false));
}
}
1 change: 1 addition & 0 deletions src/web-ui/src/flow_chat/services/FlowChatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export class FlowChatManager {
runtimeStatusTimers: new Map(),
userCancelledSessionIds: new Set(),
handledTerminalTurnEvents: new Set(),
handledPlanDisplayTurns: new Set(),
currentWorkspacePath: null
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import { FlowChatStore } from '../../store/FlowChatStore';
import type { FlowToolItem } from '../../types/flow-chat';
import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI';
import { createLogger } from '@/shared/utils/logger';

const log = createLogger('AcpPermissionToolCardModule');

const pendingAcpPermissionRequests = new Map<string, AcpPermissionRequestEvent>();

Expand All @@ -18,7 +21,7 @@ function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null {
function findToolContextById(
store: FlowChatStore,
toolId: string
): { sessionId: string; turnId: string; itemId: string } | null {
): { sessionId: string; turnId: string; itemId: string; item: FlowToolItem } | null {
const state = store.getState();
for (const [sessionId, session] of state.sessions) {
for (const turn of session.dialogTurns) {
Expand All @@ -29,7 +32,7 @@ function findToolContextById(
)) as FlowToolItem | undefined;

if (item) {
return { sessionId, turnId: turn.id, itemId: item.id };
return { sessionId, turnId: turn.id, itemId: item.id, item };
}
}
}
Expand All @@ -47,6 +50,15 @@ function applyAcpPermissionRequest(
return false;
}

// Idempotency: skip if the same permission was already applied to this tool
if (toolContext.item.acpPermission?.permissionId === event.permissionId) {
log.debug('Skipping duplicate ACP permission request', {
toolId,
permissionId: event.permissionId,
});
return true;
}

store.updateModelRoundItem(toolContext.sessionId, toolContext.turnId, toolContext.itemId, {
requiresConfirmation: true,
userConfirmed: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ function createFlowChatContext(): FlowChatContext {
runtimeStatusTimers: new Map(),
userCancelledSessionIds: new Set(),
handledTerminalTurnEvents: new Set(),
handledPlanDisplayTurns: new Set(),
currentWorkspacePath: null,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1160,18 +1160,20 @@ function finalizePendingTurnCompletion(
finalizeTurnCompletionState(context, sessionId, turnId);
}

function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): void {
function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): boolean {
const pending = context.pendingTurnCompletions.get(sessionId);
if (!pending) {
return;
return false;
}

if (pending.timer) {
clearTimeout(pending.timer);
}

context.pendingTurnCompletions.delete(sessionId);
flushPendingBatchedEvents(context);
finalizeTurnCompletionState(context, sessionId, pending.turnId);
return true;
}

function findFinishingTurnForBackendIdle(
Expand Down Expand Up @@ -1367,8 +1369,8 @@ export function handleSessionStateChanged(context: FlowChatContext, event: any):
machineContext.backendSyncedAt = Date.now();

if (isExpectedFinishingDrift) {
finalizePendingTurnCompletionNow(context, sessionId);
if (stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) {
const didFinalize = finalizePendingTurnCompletionNow(context, sessionId);
if (!didFinalize && stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) {
const finishingTurnId = findFinishingTurnForBackendIdle(
context,
sessionId,
Expand Down Expand Up @@ -2679,6 +2681,13 @@ function appendPlanDisplayItemsIfNeeded(
turnId: string,
dialogTurn: DialogTurn
): void {
const planDisplayKey = `${sessionId}:${turnId}`;
if (context.handledPlanDisplayTurns.has(planDisplayKey)) {
log.debug('Skipping duplicate plan display injection', { sessionId, turnId });
return;
}
context.handledPlanDisplayTurns.add(planDisplayKey);

const modifiedPlanFiles = detectModifiedPlanFiles(dialogTurn);
if (modifiedPlanFiles.length === 0) return;

Expand All @@ -2687,7 +2696,7 @@ function appendPlanDisplayItemsIfNeeded(

for (const planFilePath of modifiedPlanFiles) {
const planToolItem: FlowToolItem = {
id: `plan-display-${Date.now()}-${Math.random().toString(36).slice(2)}`,
id: `plan-display-${planFilePath}`,
type: 'tool',
toolName: 'CreatePlan',
toolCall: { input: {}, id: '' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('MessageModule cancellation', () => {
pendingTurnCompletions: new Map(),
runtimeStatusTimers: new Map(),
handledTerminalTurnEvents: new Set<string>(),
handledPlanDisplayTurns: new Set<string>(),
contentBuffers,
activeTextItems,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,17 @@ function handleConfirmationNeeded(
turnId: string,
toolEvent: ConfirmationNeededToolEvent
): void {
const existingItem = store.findToolItem(sessionId, turnId, toolEvent.tool_id) as FlowToolItem | null;
if (existingItem?.status === 'pending_confirmation') {
log.debug('Skipping duplicate tool confirmation needed event', {
sessionId,
turnId,
toolId: toolEvent.tool_id,
toolName: toolEvent.tool_name,
});
return;
}

store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, {
requiresConfirmation: true,
status: 'pending_confirmation',
Expand Down
7 changes: 7 additions & 0 deletions src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export interface FlowChatContext {
* this set is used to make handlers idempotent. Key format: `sessionId:turnId`.
*/
handledTerminalTurnEvents: Set<string>;
/**
* Turn IDs whose plan-display items have already been appended.
* Guards against duplicate plan display injection when finalizeTurnCompletionState
* is reached via multiple code paths for the same turn.
* Key format: `sessionId:turnId`.
*/
handledPlanDisplayTurns: Set<string>;
currentWorkspacePath: string | null;
}

Expand Down
Loading
Loading