본문으로 건너뛰기

ADR-007: 비동기 런타임 안전 패턴

상태: Accepted (2026-04-20 Proposed → Accepted 승격. 세 결정 — spawn_blocking 경계, subprocess 실행, lock poisoning 처리 — 모두 워크스페이스 전반에 구현됨. src-tauri/src/feedback_sink/mod.rs:40 에서 참조) 날짜: 2026-03-09 범위: tokio 비동기 런타임을 사용하는 모든 crate

예시 스니펫의 CoreError 문법 안내: 아래 예시는 ADR-019 이전의 tuple-variant 문법 CoreError::Internal(String) 을 사용합니다. ADR-019 이후에는 typed code 필드를 가진 struct variant 로 작성해야 합니다:

CoreError::Internal {
code: maekon_core::error_codes::InternalCode::Generic,
message: format!("..."),
}

패턴(spawn_blocking wrap, subprocess timeout, lock-poison map_err)은 동일하며, 생성 호출 지점만 새 struct shape 가 필요합니다. wire-code 계약은 ADR-019 참조.


배경

client-rust 워크스페이스는 tokio 멀티스레드 런타임 위에서 실행됩니다. 1초 스케줄러 루프(src-tauri/src/scheduler/ 정의)는 9개 백그라운드 루프 전반에서 일관되고 저지연한 task 완료를 요구합니다.

이 지연 보장을 위협하는 반복적 문제 3가지:

  1. async task 내부의 블로킹 I/Omaekon-storagerusqlite, maekon-visionxcap 화면 캡처, std::fs 호출이 작업 전체 시간 동안 tokio worker thread 를 블록합니다. worker thread 풀이 멈추면 무관한 async task 들이 그 뒤에 큐잉됩니다.

  2. 동기적 subprocess 호출std::process::Command 는 자식 프로세스가 종료될 때까지 호출 thread 를 블록합니다. macOS 의 osascript 호출(maekon-monitor/src/macos.rs)과 Linux 의 xdotool/xprintidle 호출(maekon-monitor/src/linux.rs)이 현재 동기적입니다. 멈추거나 느린 subprocess 가 worker thread 전체를 얼립니다.

  3. lock poison 시 panicMutex::lock() 또는 RwLock::read().expect() 를 사용하면 spawned task 전체에 panic 이 전파되어 task 가 조용히 종료됩니다. subprocess 실패와 하드웨어 이상에 살아남아야 하는 24/7 데스크톱 에이전트에게 조용한 task 사망은 부분적 동작 실패보다 더 나쁩니다.

Pivot 증거

이 이슈들의 직접적 lineage 를 보여주는 commit 3건:

Commit날짜경로관련성
1e8c9182026-02-26crates/maekon-monitor/src/macos.rs, crates/maekon-monitor/src/linux.rs초기 코드베이스가 모든 subprocess 호출에 std::process::Command 도입
aa038712026-02-28crates/maekon-vision/src/trigger.rs내부 가변성 리팩토링(&mut self&self) 중 lock 패턴으로 Mutex::lock().expect(...) 도입
e633ac52026-03-08crates/maekon-vision/src/trigger.rs, crates/maekon-monitor/src/input_activity.rs부분적 unwrap 정리에서 unwrap().expect() 로 교체 — 문서화된 invariant 에는 정확하나 lock poison 시에는 여전히 panic. 잔여 케이스는 graceful 처리 필요

결정

1. 블로킹 I/O 경계 (spawn_blocking)

규칙: async 컨텍스트 내부에서 ~1 ms 이상 thread 를 블록할 수 있는 모든 작업은 반드시 tokio::task::spawn_blocking 으로 오프로드해야 합니다. 적용 대상:

  • maekon-storage/src/sqlite/ 의 모든 rusqlite DB 메서드
  • maekon-vision/src/capture.rsxcap::Monitor::capture_image() 화면 캡처
  • async 함수에서 호출되는 std::fs 파일 시스템 작업 (tokio::fs 가 아님)

SQLite 권장 패턴 — with_conn helper:

// 동기 Connection을 소유한 구조체에 이 헬퍼를 추가한다
async fn with_conn<F, T>(&self, f: F) -> Result<T, CoreError>
where
F: FnOnce(&Connection) -> Result<T, CoreError> + Send + 'static,
T: Send + 'static,
{
// Arc<Mutex<Connection>>을 복제하여 클로저로 이동시킨다
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.lock().map_err(|e| {
CoreError::Internal(format!("SQLite lock poisoned: {e}"))
})?;
f(&guard)
})
.await
.map_err(|e| CoreError::Internal(format!("spawn_blocking join error: {e}")))?
}

호출 측은 얇은 wrapper 로 사용:

// 호출 측 — 동기 rusqlite 코드를 클로저 안에 작성한다
let count = self
.with_conn(|conn| {
conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
.map_err(|e| CoreError::Internal(e.to_string()))
})
.await?;

tokio::sync::Mutex 가 아닌가? tokio::sync::Mutex 는 실제 SQL 실행 동안에는 여전히 시스템 thread 를 블록합니다. spawn_blocking 경계는 블로킹 작업을 tokio 가 async worker 풀과 별도로 관리하는 전용 thread 풀로 옮겨, head-of-line blocking 을 방지합니다.


2. Subprocess 실행 패턴

규칙: async 컨텍스트에서 실행되는 모든 코드는 std::process::Command 대신 tokio::process::Command 를 사용합니다. 모든 subprocess 호출에는 명시적 timeout 이 반드시 있어야 합니다.

영향 받는 파일:

  • maekon-monitor/src/macos.rsosascript, ioreg (현재 std::process::Command 사용)
  • maekon-monitor/src/linux.rsxdotool, xprintidle (현재 std::process::Command 사용)

마이그레이션 패턴:

use tokio::process::Command;
use tokio::time::{timeout, Duration};

// osascript 호출 예시 — 5초 타임아웃 적용
async fn get_active_window_macos() -> Result<Option<WindowInfo>, CoreError> {
let output = timeout(
Duration::from_secs(5),
Command::new("osascript")
.arg("-e")
.arg(APPLESCRIPT)
.output(),
)
.await
.map_err(|_| CoreError::Internal("osascript timed out".into()))?
.map_err(|e| CoreError::Internal(format!("subprocess failed: {e}")))?;

if !output.status.success() {
return Ok(None);
}
// ... parse output
}

기본 timeout 값:

컨텍스트Timeout
Monitor command (osascript, xdotool, ioreg)5 초
OCR subprocess (maekon-vision 의 Tesseract)30 초
기타 subprocess10 초 (기본값)

Timeout 은 런타임 설정 가능하지 않으며, 각 모듈의 컴파일 타임 상수입니다. subprocess 가 일관되게 timeout 된다면, timeout 을 늘리는 게 아니라 native Rust API 로 교체하는 것이 올바른 해결책입니다.


3. Lock Poisoning 처리

규칙: Mutex::lock(), RwLock::read(), RwLock::write().expect() 또는 .unwrap() 을 절대 사용하지 마세요. 항상 .map_err() 로 lock poison 에러를 CoreError::Internal 로 전파합니다.

현재 위반 사례 (점진적 마이그레이션 대상):

파일Line위반
crates/maekon-vision/src/trigger.rs88–89.expect("SmartCaptureTrigger state lock was poisoned...")
crates/maekon-monitor/src/input_activity.rs114–115.expect("InputActivityCollector period_start lock was poisoned")

패턴:

// ❌ Wrong — 이전 task 가 lock 보유 중 panic 했으면 panic
let guard = self.state.lock().expect("lock poisoned");

// ✅ Correct — graceful degrade. 이벤트 로깅 후 에러 반환
let guard = self.state.lock().map_err(|e| {
tracing::error!(
target: "maekon::runtime",
"mutex lock poisoned — previous task may have panicked: {e}"
);
CoreError::Internal(format!("lock poisoned: {e}"))
})?;

.expect() 가 허용되는 경우: 구조적으로 PoisonError 가 나올 수 없는 값(예: AtomicU32, AtomicU64) 또는 panic 이 불가능한 컨텍스트에서만 획득되는 Mutex guard(예: 실패 가능한 코드가 절대 mutate 하지 않는 Mutex<Vec<_>>). 이런 경우 .expect() 호출 위 주석으로 invariant 를 명시 문서화합니다.

근거: tokio task 가 Mutex 를 보유한 채 panic 하면 lock 이 poisoned 상태가 됩니다. 다른 task 의 후속 .lock().expect() 도 panic 하여 실패가 cascade 됩니다. 24/7 동작하면서 시스템 상태를 모니터링하는 데스크톱 에이전트는 poisoned lock 이벤트를 로깅하고, 현재 작업을 건너뛰고, 다음 tick 에 데이터 수집을 계속해야 합니다. 에이전트는 개별 모니터링 task 의 부분 실패에 resilient 해야 합니다.


결과

긍정

  • tokio worker thread 가 async 스케줄링을 위해 자유롭게 유지되며, 블로킹 작업은 spawn_blocking 풀로 격리됨.
  • 멈춘 subprocess 가 설정된 timeout 이상으로 worker thread 를 freeze 하지 않음.
  • 단일 panic task 가 sibling task 로 lock poison 실패를 cascade 할 수 없음.
  • SQLite 또는 화면 캡처가 느려도 non-blocking task 의 1초 스케줄러 지연 보장이 유지됨.

부정 / 트레이드오프

  • spawn_blocking 은 SQLite 호출당 context-switch 오버헤드 1회를 추가합니다. SQLite 지연이 이미 작업 시간을 지배하므로 수용 가능.
  • tokio::process::Command 는 순수 동기 컨텍스트에서는 사용 불가. 비-async caller 는 작은 async block 을 spawn 하거나 async 경계에서 호출하도록 재구성해야 함. 실제로는 영향 받는 모든 monitor 함수가 이미 async 스케줄러 루프에서 호출됨.
  • with_conn 은 plain Connection 이 아닌 Arc<Mutex<Connection>> 을 요구. 기존 SqliteStorage 구현체는 검토 후 업데이트 필요.

마이그레이션 경로

신규 코드는 본 ADR 채택일 이후 이 패턴들을 따라야 합니다.

기존 위반은 다음 우선순위 순으로 점진적 마이그레이션:

  1. Highmaekon-monitor/src/macos.rs + maekon-monitor/src/linux.rs: subprocess 호출이 모든 monitor 루프 tick 에 영향.
  2. Mediummaekon-vision/src/trigger.rs + maekon-monitor/src/input_activity.rs: lock poison 처리(이들은 contention 이 낮은 lock 이라 risk 가 낮으나 일관성을 위해 패턴 정정 필요).
  3. Lowmaekon-storage/src/sqlite/: 이미 전용 스케줄러 루프 task 내에서 실행되므로, 순수 churn 을 피해 향후 schema 변경과 함께 with_conn 으로 마이그레이션.

코드 리뷰 체크리스트

crates/ 하위 파일 PR 리뷰에 다음 검사를 추가합니다:

  • diff 가 async 함수에 std::process::Command 를 도입하는가? 그렇다면 tokio::process::Command + timeout 으로 교체.
  • diff 가 async 함수에서 std::fs 함수를 직접 호출하는가? 그렇다면 tokio::fs 또는 spawn_blocking 사용.
  • diff 가 std::sync primitive 에 .lock(), .read(), .write() 를 호출하는가? 결과가 .expect() / .unwrap() 이 아닌 .map_err(...) 를 사용하는지 검증.
  • 모든 신규 spawn_blocking 클로저가 Send + 'static 인가? 빌린 참조가 클로저로 escape 하지 않는지 검증.

관련 ADR