diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index e791063..2a7c856 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -944,21 +944,36 @@ const BluetoothMode = (function() { } } - async function stopScan() { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' }); - } else { - await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); - } - setScanning(false); - stopEventStream(); - } catch (err) { - console.error('Failed to stop scan:', err); - } - } + async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + // Optimistic UI teardown keeps mode changes responsive. + setScanning(false); + stopEventStream(); + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else { + await fetch('/api/bluetooth/scan/stop', { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (err) { + console.error('Failed to stop scan:', err); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning) { isScanning = scanning; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 6294f84..35c93c0 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -572,8 +572,8 @@ const WiFiMode = (function() { } } - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); // Stop polling if (pollTimer) { @@ -585,26 +585,41 @@ const WiFiMode = (function() { stopAgentDeepScanPolling(); // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } - - setScanning(false); - } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning, mode = null) { isScanning = scanning; diff --git a/templates/index.html b/templates/index.html index 980430d..0e032eb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3602,26 +3602,89 @@ } } + const LOCAL_STOP_TIMEOUT_MS = 2200; + const REMOTE_STOP_TIMEOUT_MS = 8000; + + function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) { + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + const started = performance.now(); + return fetch(url, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }) + .then((response) => response.json().catch(() => ({ status: response.ok ? 'ok' : 'error' }))) + .catch((err) => { + if (err && err.name === 'AbortError') { + console.warn(`[Stop] ${url} timed out after ${timeoutMs}ms`); + return { status: 'timeout', timed_out: true }; + } + console.warn(`[Stop] ${url} failed: ${err?.message || err}`); + return { status: 'error', message: err?.message || String(err) }; + }) + .finally(() => { + if (timeoutId) clearTimeout(timeoutId); + const elapsedMs = Math.round(performance.now() - started); + console.debug(`[Stop] ${url} finished in ${elapsedMs}ms`); + }); + } + + async function awaitStopAction(name, action, timeoutMs = LOCAL_STOP_TIMEOUT_MS) { + const started = performance.now(); + try { + const result = action(); + const promise = (result && typeof result.then === 'function') + ? result + : Promise.resolve(result); + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + } catch (err) { + console.warn(`[ModeSwitch] stop ${name} failed: ${err?.message || err}`); + } finally { + const elapsedMs = Math.round(performance.now() - started); + console.debug(`[ModeSwitch] stop ${name} finished in ${elapsedMs}ms`); + } + } + // Mode switching - function switchMode(mode, options = {}) { + async function switchMode(mode, options = {}) { const { updateUrl = true } = options; if (mode === 'listening') mode = 'waterfall'; if (!validModes.has(mode)) mode = 'pager'; // Only stop local scans if in local mode (not agent mode) const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; if (!isAgentMode) { - if (isRunning) stopDecoding(); - if (isSensorRunning) stopSensorDecoding(); - if (isWifiRunning) stopWifiScan(); + if (isRunning) { + await awaitStopAction('pager', () => stopDecoding(), LOCAL_STOP_TIMEOUT_MS); + } + if (isSensorRunning) { + await awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS); + } + const wifiScanActive = ( + typeof WiFiMode !== 'undefined' + && typeof WiFiMode.isScanning === 'function' + && WiFiMode.isScanning() + ) || isWifiRunning; + if (wifiScanActive) { + await awaitStopAction('wifi', () => stopWifiScan(), LOCAL_STOP_TIMEOUT_MS); + } const btScanActive = (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) || isBtRunning; const isBtModeTransition = (currentMode === 'bluetooth' && mode === 'bt_locate') || (currentMode === 'bt_locate' && mode === 'bluetooth'); - if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') stopBtScan(); - if (isAprsRunning) stopAprs(); - if (isTscmRunning) stopTscmSweep(); + if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') { + await awaitStopAction('bluetooth', () => stopBtScan(), LOCAL_STOP_TIMEOUT_MS); + } + if (isAprsRunning) { + await awaitStopAction('aprs', () => stopAprs(), LOCAL_STOP_TIMEOUT_MS); + } + if (isTscmRunning) { + await awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS); + } } // Clean up SubGHz SSE connection when leaving the mode @@ -4343,35 +4406,31 @@ // Stop sensor decoding function stopSensorDecoding() { - // Check if using remote agent - if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { - fetch(`/controller/agents/${currentAgent}/sensor/stop`, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - setSensorRunning(false); - if (eventSource) { - eventSource.close(); - eventSource = null; - } - if (agentPollInterval) { - clearInterval(agentPollInterval); - agentPollInterval = null; - } - showInfo('Sensor stopped on remote agent'); - }); - return; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/sensor/stop` + : '/stop_sensor'; + const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS; + + setSensorRunning(false); + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (agentPollInterval) { + clearInterval(agentPollInterval); + agentPollInterval = null; + } + if (!isAgentMode) { + releaseDevice('sensor'); } - fetch('/stop_sensor', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - releaseDevice('sensor'); - setSensorRunning(false); - if (eventSource) { - eventSource.close(); - eventSource = null; - } - }); + return postStopRequest(endpoint, timeoutMs).then((data) => { + if (isAgentMode && data && data.status !== 'error' && data.status !== 'timeout') { + showInfo('Sensor stopped on remote agent'); + } + return data; + }); } // Polling interval for agent data @@ -4685,25 +4744,23 @@ const endpoint = isAgentMode ? `/controller/agents/${rtlamrCurrentAgent}/rtlamr/stop` : '/stop_rtlamr'; + const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - if (!isAgentMode) { - releaseDevice('rtlamr'); - } - rtlamrCurrentAgent = null; - setRtlamrRunning(false); - if (eventSource) { - eventSource.close(); - eventSource = null; - } - // Clear polling timer - if (rtlamrPollTimer) { - clearInterval(rtlamrPollTimer); - rtlamrPollTimer = null; - } - }); + rtlamrCurrentAgent = null; + setRtlamrRunning(false); + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (rtlamrPollTimer) { + clearInterval(rtlamrPollTimer); + rtlamrPollTimer = null; + } + if (!isAgentMode) { + releaseDevice('rtlamr'); + } + + return postStopRequest(endpoint, timeoutMs); } function setRtlamrRunning(running) { @@ -5727,24 +5784,22 @@ const endpoint = isAgentMode ? `/controller/agents/${currentAgent}/pager/stop` : '/stop'; + const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - if (!isAgentMode) { - releaseDevice('pager'); - } - setRunning(false); - if (eventSource) { - eventSource.close(); - eventSource = null; - } - // Clear polling timer if active - if (pagerPollTimer) { - clearInterval(pagerPollTimer); - pagerPollTimer = null; - } - }); + if (!isAgentMode) { + releaseDevice('pager'); + } + setRunning(false); + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pagerPollTimer) { + clearInterval(pagerPollTimer); + pagerPollTimer = null; + } + + return postStopRequest(endpoint, timeoutMs); } function killAll() { @@ -7642,15 +7697,19 @@ // Stop WiFi scan function stopWifiScan() { - fetch('/wifi/scan/stop', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - setWifiRunning(false); - if (wifiEventSource) { - wifiEventSource.close(); - wifiEventSource = null; - } + setWifiRunning(false); + if (wifiEventSource) { + wifiEventSource.close(); + wifiEventSource = null; + } + + if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { + return Promise.resolve(WiFiMode.stopScan()).catch((err) => { + console.warn('[WiFi] stop via WiFiMode failed:', err); }); + } + + return postStopRequest('/wifi/scan/stop', LOCAL_STOP_TIMEOUT_MS); } function setWifiRunning(running) { @@ -8856,10 +8915,14 @@ function stopBtScan() { const bt = getBluetoothModeApi(); + let stopPromise = Promise.resolve(); if (bt && typeof bt.stopScan === 'function') { - bt.stopScan(); + stopPromise = Promise.resolve(bt.stopScan()).catch((err) => { + console.warn('[BT] stop failed:', err); + }); } setTimeout(syncBtRunningState, 0); + return stopPromise; } function setBtRunning(running) { @@ -9175,44 +9238,36 @@ const endpoint = isAgentMode ? `/controller/agents/${aprsCurrentAgent}/aprs/stop` : '/aprs/stop'; + const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - isAprsRunning = false; - aprsCurrentAgent = null; - // Update function bar buttons - document.getElementById('aprsStripStartBtn').style.display = 'inline-block'; - document.getElementById('aprsStripStopBtn').style.display = 'none'; - // Update map status - document.getElementById('aprsMapStatus').textContent = 'STANDBY'; - document.getElementById('aprsMapStatus').style.color = ''; - // Reset function bar status - updateAprsStatus('standby'); - document.getElementById('aprsStripFreq').textContent = '--'; - document.getElementById('aprsStripSignal').textContent = '--'; - // Re-enable controls - document.getElementById('aprsStripRegion').disabled = false; - document.getElementById('aprsStripGain').disabled = false; - const customFreqInput = document.getElementById('aprsStripCustomFreq'); - if (customFreqInput) customFreqInput.disabled = false; - // Remove signal quality class - const signalStat = document.getElementById('aprsStripSignalStat'); - if (signalStat) { - signalStat.classList.remove('good', 'warning', 'poor'); - } - // Stop meter check interval - stopAprsMeterCheck(); - if (aprsEventSource) { - aprsEventSource.close(); - aprsEventSource = null; - } - // Clear polling timer - if (aprsPollTimer) { - clearInterval(aprsPollTimer); - aprsPollTimer = null; - } - }); + isAprsRunning = false; + aprsCurrentAgent = null; + document.getElementById('aprsStripStartBtn').style.display = 'inline-block'; + document.getElementById('aprsStripStopBtn').style.display = 'none'; + document.getElementById('aprsMapStatus').textContent = 'STANDBY'; + document.getElementById('aprsMapStatus').style.color = ''; + updateAprsStatus('standby'); + document.getElementById('aprsStripFreq').textContent = '--'; + document.getElementById('aprsStripSignal').textContent = '--'; + document.getElementById('aprsStripRegion').disabled = false; + document.getElementById('aprsStripGain').disabled = false; + const customFreqInput = document.getElementById('aprsStripCustomFreq'); + if (customFreqInput) customFreqInput.disabled = false; + const signalStat = document.getElementById('aprsStripSignalStat'); + if (signalStat) { + signalStat.classList.remove('good', 'warning', 'poor'); + } + stopAprsMeterCheck(); + if (aprsEventSource) { + aprsEventSource.close(); + aprsEventSource = null; + } + if (aprsPollTimer) { + clearInterval(aprsPollTimer); + aprsPollTimer = null; + } + + return postStopRequest(endpoint, timeoutMs); } function startAprsStream(isAgentMode = false) { @@ -10902,16 +10957,11 @@ } async function stopTscmSweep() { - try { - // Route to agent or local based on selection - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const endpoint = isAgentMode - ? `/controller/agents/${currentAgent}/tscm/stop` - : '/tscm/sweep/stop'; - await fetch(endpoint, { method: 'POST' }); - } catch (e) { - console.error('Error stopping sweep:', e); - } + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/tscm/stop` + : '/tscm/sweep/stop'; + const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS; isTscmRunning = false; tscmSweepEndTime = new Date(); @@ -10931,6 +10981,8 @@ // Show report button if we have any data const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0; document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none'; + + return postStopRequest(endpoint, timeoutMs); } function generateTscmReport() { diff --git a/utils/agent_client.py b/utils/agent_client.py index 001f4a0..e85947f 100644 --- a/utils/agent_client.py +++ b/utils/agent_client.py @@ -97,7 +97,7 @@ class AgentClient: except requests.RequestException as e: raise AgentHTTPError(f"Request failed: {e}") - def _post(self, path: str, data: dict | None = None) -> dict: + def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: """ Perform POST request to agent. @@ -112,20 +112,21 @@ class AgentClient: AgentHTTPError: On HTTP errors AgentConnectionError: If agent is unreachable """ - url = f"{self.base_url}{path}" - try: - response = requests.post( - url, - json=data or {}, - headers=self._headers(), - timeout=self.timeout - ) - response.raise_for_status() - return response.json() if response.content else {} - except requests.ConnectionError as e: - raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}") - except requests.Timeout: - raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s") + url = f"{self.base_url}{path}" + request_timeout = self.timeout if timeout is None else timeout + try: + response = requests.post( + url, + json=data or {}, + headers=self._headers(), + timeout=request_timeout + ) + response.raise_for_status() + return response.json() if response.content else {} + except requests.ConnectionError as e: + raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}") + except requests.Timeout: + raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s") except requests.HTTPError as e: # Try to extract error message from response body error_msg = f"Agent returned error: {e.response.status_code}" @@ -141,9 +142,9 @@ class AgentClient: except requests.RequestException as e: raise AgentHTTPError(f"Request failed: {e}") - def post(self, path: str, data: dict | None = None) -> dict: - """Public POST method for arbitrary endpoints.""" - return self._post(path, data) + def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: + """Public POST method for arbitrary endpoints.""" + return self._post(path, data, timeout=timeout) # ========================================================================= # Capability & Status @@ -214,7 +215,7 @@ class AgentClient: """ return self._post(f'/{mode}/start', params or {}) - def stop_mode(self, mode: str) -> dict: + def stop_mode(self, mode: str, timeout: float = 8.0) -> dict: """ Stop a running mode on the agent. @@ -224,7 +225,7 @@ class AgentClient: Returns: Stop result with 'status' field """ - return self._post(f'/{mode}/stop') + return self._post(f'/{mode}/stop', timeout=timeout) def get_mode_status(self, mode: str) -> dict: """ diff --git a/utils/wifi/scanner.py b/utils/wifi/scanner.py index 9951107..d99a4e3 100644 --- a/utils/wifi/scanner.py +++ b/utils/wifi/scanner.py @@ -726,46 +726,76 @@ class UnifiedWiFiScanner: return True - def stop_deep_scan(self) -> bool: - """ - Stop the deep scan. - - Returns: - True if scan was stopped. - """ - with self._lock: - if not self._status.is_scanning: - return True - - # Stop deauth detector first - self._stop_deauth_detector() - - self._deep_scan_stop_event.set() - - if self._deep_scan_process: - try: - self._deep_scan_process.terminate() - self._deep_scan_process.wait(timeout=5) - except Exception as e: - logger.warning(f"Error terminating airodump-ng: {e}") - try: - self._deep_scan_process.kill() - except Exception: - pass - self._deep_scan_process = None - - if self._deep_scan_thread: - self._deep_scan_thread.join(timeout=5) - self._deep_scan_thread = None - - self._status.is_scanning = False - - self._queue_event({ - 'type': 'scan_stopped', - 'mode': SCAN_MODE_DEEP, - }) - - return True + def stop_deep_scan(self) -> bool: + """ + Stop the deep scan. + + Returns: + True if scan was stopped. + """ + cleanup_process: Optional[subprocess.Popen] = None + cleanup_thread: Optional[threading.Thread] = None + cleanup_detector = None + + with self._lock: + if not self._status.is_scanning: + return True + + self._deep_scan_stop_event.set() + cleanup_process = self._deep_scan_process + cleanup_thread = self._deep_scan_thread + cleanup_detector = self._deauth_detector + self._deauth_detector = None + self._deep_scan_process = None + self._deep_scan_thread = None + + self._status.is_scanning = False + self._status.error = None + + self._queue_event({ + 'type': 'scan_stopped', + 'mode': SCAN_MODE_DEEP, + }) + + cleanup_start = time.perf_counter() + + def _finalize_stop( + process: Optional[subprocess.Popen], + scan_thread: Optional[threading.Thread], + detector, + ) -> None: + if detector: + try: + detector.stop() + logger.info("Deauth detector stopped") + self._queue_event({'type': 'deauth_detector_stopped'}) + except Exception as exc: + logger.error(f"Error stopping deauth detector: {exc}") + + if process and process.poll() is None: + try: + process.terminate() + process.wait(timeout=1.5) + except Exception: + try: + process.kill() + except Exception: + pass + + if scan_thread and scan_thread.is_alive(): + scan_thread.join(timeout=1.5) + + elapsed_ms = (time.perf_counter() - cleanup_start) * 1000.0 + logger.info(f"Deep scan stop finalized in {elapsed_ms:.1f}ms") + + threading.Thread( + target=_finalize_stop, + args=(cleanup_process, cleanup_thread, cleanup_detector), + daemon=True, + name='wifi-deep-stop', + ).start() + + return True def _run_deep_scan( self, @@ -799,14 +829,32 @@ class UnifiedWiFiScanner: logger.info(f"Starting airodump-ng: {' '.join(cmd)}") - try: - self._deep_scan_process = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - csv_file = f"{output_prefix}-01.csv" + process: Optional[subprocess.Popen] = None + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + should_track_process = False + with self._lock: + # Only expose the process handle if this run has not been + # replaced by a newer deep scan session. + if self._status.is_scanning and not self._deep_scan_stop_event.is_set(): + should_track_process = True + self._deep_scan_process = process + if not should_track_process: + try: + process.terminate() + process.wait(timeout=1.0) + except Exception: + try: + process.kill() + except Exception: + pass + return + + csv_file = f"{output_prefix}-01.csv" # Poll CSV file for updates while not self._deep_scan_stop_event.is_set(): @@ -830,14 +878,16 @@ class UnifiedWiFiScanner: except Exception as e: logger.debug(f"Error parsing airodump CSV: {e}") - except Exception as e: - logger.exception(f"Deep scan error: {e}") - self._queue_event({ - 'type': 'scan_error', - 'error': str(e), - }) - finally: - self._deep_scan_process = None + except Exception as e: + logger.exception(f"Deep scan error: {e}") + self._queue_event({ + 'type': 'scan_error', + 'error': str(e), + }) + finally: + with self._lock: + if process is not None and self._deep_scan_process is process: + self._deep_scan_process = None # ========================================================================= # Observation Processing