본문으로 건너뛰기

Tauri v2 CLI Subprocess 관리 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Tauri v2 데스크탑 앱에서 CLI 프로세스를 실행/관리하는 패턴 공통 참조: ../common/security.md (보안), ./architecture.md (IPC, Sidecar 기본) 참고 구현: chorus-cli, claude-code-webui 방식


목차

  1. CLI Subprocess 아키텍처
  2. Command API로 프로세스 실행
  3. JSON 스트리밍 파싱
  4. 세션 관리
  5. 에러 핸들링
  6. 멀티 프로세스 관리
  7. Sidecar로 CLI 번들링
  8. 보안

1. CLI Subprocess 아키텍처

전체 데이터 흐름

+-------------------+     IPC      +-------------------+     spawn     +----------+
| Frontend | ──invoke──> | Rust Core | ──────────> | CLI |
| (TypeScript) | | | | Process |
| | <──channel── | - ProcessMgr | <──stdout── | |
| - UI 렌더링 | streaming | - SessionMgr | (JSON) | |
| - 상태 관리 | | - ErrorHandler | <──stderr── | |
+-------------------+ +-------------------+ +----------+

실행 방식 비교

방식장점단점사용 시점
Command.execute()간단, 결과 한 번에 반환스트리밍 불가짧은 명령 (ls, cat 등)
Command.spawn()stdout/stderr 실시간 스트리밍관리 복잡장시간 프로세스, 대화형 CLI
Rust tokio::process완전 제어, 복잡한 파이프프론트엔드 연동 필요고급 프로세스 관리

2. Command API로 프로세스 실행

2.1 단순 실행 (execute)

// Frontend: 단순 명령 실행 후 결과 수신
import { Command } from '@tauri-apps/plugin-shell';

async function runSimpleCommand(): Promise<string> {
const command = Command.create('exec-sh', ['-c', 'echo hello']);
const output = await command.execute();

if (output.code !== 0) {
throw new Error(`Command failed: ${output.stderr}`);
}
return output.stdout;
}

2.2 스트리밍 실행 (spawn)

// Frontend: 장시간 프로세스의 stdout을 실시간 수신
import { Command } from '@tauri-apps/plugin-shell';

interface SpawnResult {
pid: number;
kill: () => void;
}

function spawnStreamingProcess(
args: string[],
onStdout: (line: string) => void,
onStderr: (line: string) => void,
onClose: (code: number | null) => void,
): SpawnResult {
const command = Command.sidecar('my-cli', args);

command.stdout.on('data', (line: string) => {
onStdout(line);
});

command.stderr.on('data', (line: string) => {
onStderr(line);
});

command.on('close', (data) => {
onClose(data.code);
});

command.on('error', (error) => {
onStderr(`Process error: ${error}`);
});

const child = command.spawn();
return {
pid: child.pid,
kill: () => child.kill(),
};
}

2.3 Rust 측 프로세스 관리 (고급)

// src-tauri/src/services/process_runner.rs
use std::process::Stdio;
use tokio::process::Command as TokioCommand;
use tokio::io::{AsyncBufReadExt, BufReader};
use tauri::ipc::Channel;
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum ProcessOutput {
#[serde(rename_all = "camelCase")]
Stdout { line: String },
#[serde(rename_all = "camelCase")]
Stderr { line: String },
#[serde(rename_all = "camelCase")]
Exit { code: Option<i32> },
#[serde(rename_all = "camelCase")]
Error { message: String },
}

#[tauri::command]
pub async fn spawn_cli_process(
command: String,
args: Vec<String>,
on_output: Channel<ProcessOutput>,
) -> Result<u32, String> {
let mut child = TokioCommand::new(&command)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.kill_on_drop(true) // 부모 종료 시 자동 kill
.spawn()
.map_err(|e| format!("Failed to spawn: {}", e))?;

let pid = child.id().unwrap_or(0);

// stdout 스트리밍
let stdout = child.stdout.take().unwrap();
let on_output_stdout = on_output.clone();
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = on_output_stdout.send(ProcessOutput::Stdout { line });
}
});

// stderr 스트리밍
let stderr = child.stderr.take().unwrap();
let on_output_stderr = on_output.clone();
tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = on_output_stderr.send(ProcessOutput::Stderr { line });
}
});

// 프로세스 완료 대기
let on_output_exit = on_output.clone();
tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
let _ = on_output_exit.send(ProcessOutput::Exit {
code: status.code(),
});
}
Err(e) => {
let _ = on_output_exit.send(ProcessOutput::Error {
message: e.to_string(),
});
}
}
});

Ok(pid)
}

2.4 stdin 쓰기 (대화형 CLI)

// Rust: stdin을 통한 대화형 프로세스 관리
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::io::AsyncWriteExt;
use tokio::process::ChildStdin;

pub struct ProcessManager {
processes: HashMap<String, Arc<Mutex<ChildStdin>>>,
}

impl ProcessManager {
pub fn new() -> Self {
Self {
processes: HashMap::new(),
}
}

pub async fn write_stdin(
&self,
process_id: &str,
input: &str,
) -> Result<(), String> {
let stdin = self.processes.get(process_id)
.ok_or_else(|| format!("Process {} not found", process_id))?;

let mut stdin = stdin.lock().await;
stdin.write_all(input.as_bytes())
.await
.map_err(|e| e.to_string())?;
stdin.write_all(b"\n")
.await
.map_err(|e| e.to_string())?;
stdin.flush()
.await
.map_err(|e| e.to_string())?;

Ok(())
}
}
// Frontend: 대화형 프로세스에 입력 전송
import { invoke } from '@tauri-apps/api/core';

async function sendInput(processId: string, message: string): Promise<void> {
await invoke('write_to_process', {
processId,
input: JSON.stringify({ type: 'user_message', content: message }),
});
}

3. JSON 스트리밍 파싱

CLI 도구가 줄 단위 JSON (JSONL/NDJSON)을 출력할 때의 파싱 패턴.

3.1 JSONL 파서 (TypeScript)

// src/lib/utils/jsonl-parser.ts

export interface ParsedMessage<T = unknown> {
type: string;
data: T;
raw: string;
}

export class JsonLineParser<T = unknown> {
private buffer: string = '';
private onMessage: (msg: ParsedMessage<T>) => void;
private onError: (error: Error, raw: string) => void;

constructor(
onMessage: (msg: ParsedMessage<T>) => void,
onError?: (error: Error, raw: string) => void,
) {
this.onMessage = onMessage;
this.onError = onError ?? ((err, raw) => console.warn('Parse error:', err, raw));
}

/**
* stdout 데이터를 수신하여 줄 단위로 분리 후 JSON 파싱.
* 불완전한 줄은 버퍼에 유지한다.
*/
feed(chunk: string): void {
this.buffer += chunk;
const lines = this.buffer.split('\n');

// 마지막 줄은 불완전할 수 있으므로 버퍼에 보관
this.buffer = lines.pop() ?? '';

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;

try {
const parsed = JSON.parse(trimmed) as T & { type?: string };
this.onMessage({
type: parsed.type ?? 'unknown',
data: parsed,
raw: trimmed,
});
} catch (error) {
this.onError(error as Error, trimmed);
}
}
}

/** 버퍼에 남은 데이터 플러시 (프로세스 종료 시 호출) */
flush(): void {
const trimmed = this.buffer.trim();
if (trimmed) {
try {
const parsed = JSON.parse(trimmed) as T & { type?: string };
this.onMessage({
type: parsed.type ?? 'unknown',
data: parsed,
raw: trimmed,
});
} catch (error) {
this.onError(error as Error, trimmed);
}
}
this.buffer = '';
}
}

3.2 Rust 측 JSON 스트리밍 파서

// src-tauri/src/services/json_stream.rs
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::ChildStdout;

pub async fn parse_json_stream<T, F>(
stdout: ChildStdout,
mut on_message: F,
) where
T: DeserializeOwned,
F: FnMut(Result<T, serde_json::Error>, String),
{
let reader = BufReader::new(stdout);
let mut lines = reader.lines();

while let Ok(Some(line)) = lines.next_line().await {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}

let result = serde_json::from_str::<T>(&trimmed);
on_message(result, trimmed);
}
}

3.3 사용 예시: AI CLI 응답 스트리밍

// src/lib/api/ai-session.ts
import { Command, type ChildProcess } from '@tauri-apps/plugin-shell';
import { JsonLineParser } from '$lib/utils/jsonl-parser';

interface AiResponse {
type: 'assistant' | 'result' | 'error' | 'system';
content?: string;
session_id?: string;
is_final?: boolean;
}

export function streamAiResponse(
sessionId: string,
message: string,
onChunk: (response: AiResponse) => void,
onComplete: () => void,
onError: (error: string) => void,
): { cancel: () => void } {
const parser = new JsonLineParser<AiResponse>(
(msg) => onChunk(msg.data),
(err, raw) => onError(`Parse error: ${err.message} (raw: ${raw})`),
);

const command = Command.sidecar('ai-cli', [
'--session', sessionId,
'--format', 'jsonl',
'chat', message,
]);

let child: ChildProcess | null = null;

command.stdout.on('data', (line: string) => {
parser.feed(line + '\n');
});

command.stderr.on('data', (line: string) => {
onError(line);
});

command.on('close', (data) => {
parser.flush();
if (data.code === 0) {
onComplete();
} else {
onError(`Process exited with code ${data.code}`);
}
});

command.spawn().then((c) => { child = c; });

return {
cancel: () => {
if (child) child.kill();
},
};
}

4. 세션 관리

CLI 기반 대화형 도구(AI 에이전트 등)에서 session_id로 대화 컨텍스트를 유지하는 패턴.

4.1 세션 상태 관리 (Rust)

// src-tauri/src/state/session.rs
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub model: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_active: chrono::DateTime<chrono::Utc>,
pub message_count: usize,
pub status: SessionStatus,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SessionStatus {
Active,
Idle,
Processing,
Error(String),
Terminated,
}

pub struct SessionManager {
sessions: Mutex<HashMap<String, Session>>,
}

impl SessionManager {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
}
}

pub async fn create(&self, model: String) -> Result<Session, String> {
let session = Session {
id: Uuid::new_v4().to_string(),
model,
created_at: chrono::Utc::now(),
last_active: chrono::Utc::now(),
message_count: 0,
status: SessionStatus::Active,
};

self.sessions
.lock()
.await
.insert(session.id.clone(), session.clone());

Ok(session)
}

pub async fn get(&self, session_id: &str) -> Option<Session> {
self.sessions.lock().await.get(session_id).cloned()
}

pub async fn update_status(
&self,
session_id: &str,
status: SessionStatus,
) -> Result<(), String> {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.status = status;
session.last_active = chrono::Utc::now();
Ok(())
}

pub async fn remove(&self, session_id: &str) -> Option<Session> {
self.sessions.lock().await.remove(session_id)
}

pub async fn list(&self) -> Vec<Session> {
self.sessions.lock().await.values().cloned().collect()
}

/// 유휴 세션 정리 (idle_timeout 초과 시 종료)
pub async fn cleanup_idle(&self, idle_timeout: chrono::Duration) -> Vec<String> {
let now = chrono::Utc::now();
let mut sessions = self.sessions.lock().await;
let idle_ids: Vec<String> = sessions
.iter()
.filter(|(_, s)| {
s.status == SessionStatus::Idle
&& now - s.last_active > idle_timeout
})
.map(|(id, _)| id.clone())
.collect();

for id in &idle_ids {
if let Some(session) = sessions.get_mut(id) {
session.status = SessionStatus::Terminated;
}
}
idle_ids
}
}

4.2 세션 IPC 커맨드

// src-tauri/src/commands/session.rs
use tauri::State;
use crate::state::{AppState, session::{Session, SessionStatus}};

#[tauri::command]
pub async fn create_session(
state: State<'_, AppState>,
model: String,
) -> Result<Session, String> {
state.session_manager.create(model).await
}

#[tauri::command]
pub async fn list_sessions(
state: State<'_, AppState>,
) -> Result<Vec<Session>, String> {
Ok(state.session_manager.list().await)
}

#[tauri::command]
pub async fn terminate_session(
state: State<'_, AppState>,
session_id: String,
) -> Result<(), String> {
// CLI 프로세스 종료
state.process_manager.lock().await.kill(&session_id).await?;
// 세션 상태 업데이트
state.session_manager
.update_status(&session_id, SessionStatus::Terminated)
.await
}

4.3 세션 영속화 (tauri-plugin-store)

// src/lib/stores/session-persistence.ts
import { Store } from '@tauri-apps/plugin-store';

const SESSION_STORE = 'sessions.json';

interface PersistedSession {
id: string;
model: string;
createdAt: string;
messageHistory: Array<{ role: string; content: string }>;
}

export class SessionPersistence {
private store: Store | null = null;

async init(): Promise<void> {
this.store = await Store.load(SESSION_STORE, { autoSave: true });
}

async save(session: PersistedSession): Promise<void> {
await this.store?.set(session.id, session);
}

async load(sessionId: string): Promise<PersistedSession | null> {
return await this.store?.get<PersistedSession>(sessionId) ?? null;
}

async listAll(): Promise<PersistedSession[]> {
const keys = await this.store?.keys() ?? [];
const sessions: PersistedSession[] = [];
for (const key of keys) {
const session = await this.store?.get<PersistedSession>(key);
if (session) sessions.push(session);
}
return sessions;
}

async delete(sessionId: string): Promise<void> {
await this.store?.delete(sessionId);
}
}

5. 에러 핸들링

5.1 에러 유형 분류

에러 유형원인대응
Spawn 실패바이너리 없음, 권한 부족사용자에게 설치 안내
프로세스 크래시비정상 종료 (SIGSEGV 등)자동 재시작 + 에러 리포트
타임아웃응답 없음프로세스 kill + 알림
파싱 에러JSON 형식 불일치로그 기록 + 무시 또는 재시도
stderr 출력경고/에러 메시지로그 레벨에 따라 분기
stdin 쓰기 실패파이프 깨짐프로세스 재시작

5.2 에러 핸들러 (Rust)

// src-tauri/src/services/error_handler.rs
use serde::Serialize;
use std::time::Duration;
use tokio::time::timeout;

#[derive(Debug, Clone, Serialize)]
pub struct ProcessError {
pub process_id: String,
pub error_type: ProcessErrorType,
pub message: String,
pub recoverable: bool,
}

#[derive(Debug, Clone, Serialize)]
pub enum ProcessErrorType {
SpawnFailed,
Crashed,
Timeout,
ParseError,
StdinBroken,
}

pub struct ProcessGuard {
timeout_duration: Duration,
max_retries: u32,
}

impl ProcessGuard {
pub fn new(timeout_secs: u64, max_retries: u32) -> Self {
Self {
timeout_duration: Duration::from_secs(timeout_secs),
max_retries,
}
}

/// 타임아웃 래퍼: 지정 시간 내 응답 없으면 에러
pub async fn with_timeout<F, T>(&self, future: F) -> Result<T, ProcessError>
where
F: std::future::Future<Output = Result<T, String>>,
{
match timeout(self.timeout_duration, future).await {
Ok(result) => result.map_err(|msg| ProcessError {
process_id: String::new(),
error_type: ProcessErrorType::Crashed,
message: msg,
recoverable: true,
}),
Err(_) => Err(ProcessError {
process_id: String::new(),
error_type: ProcessErrorType::Timeout,
message: format!(
"Process timed out after {} seconds",
self.timeout_duration.as_secs()
),
recoverable: true,
}),
}
}

/// 자동 재시도
pub async fn with_retry<F, Fut, T>(
&self,
mut factory: F,
) -> Result<T, ProcessError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ProcessError>>,
{
let mut last_error = None;
for attempt in 0..=self.max_retries {
match factory().await {
Ok(result) => return Ok(result),
Err(err) => {
if !err.recoverable || attempt == self.max_retries {
return Err(err);
}
last_error = Some(err);
// 지수 백오프
let delay = Duration::from_millis(100 * 2u64.pow(attempt));
tokio::time::sleep(delay).await;
}
}
}
Err(last_error.unwrap())
}
}

5.3 프론트엔드 에러 UI

// src/lib/stores/process-error.ts
import { writable } from 'svelte/store';

export interface ProcessErrorInfo {
processId: string;
type: 'spawn_failed' | 'crashed' | 'timeout' | 'parse_error';
message: string;
recoverable: boolean;
timestamp: number;
}

export const processErrors = writable<ProcessErrorInfo[]>([]);

export function addProcessError(error: ProcessErrorInfo): void {
processErrors.update((errors) => {
// 최대 50개 유지
const updated = [...errors, error];
return updated.slice(-50);
});
}

export function clearProcessErrors(processId: string): void {
processErrors.update((errors) =>
errors.filter((e) => e.processId !== processId)
);
}

6. 멀티 프로세스 관리

6.1 프로세스 매니저 (Rust)

// src-tauri/src/services/process_manager.rs
use std::collections::HashMap;
use tokio::process::Child;
use tokio::sync::Mutex;

pub struct ManagedProcess {
pub child: Child,
pub session_id: String,
pub command: String,
pub started_at: chrono::DateTime<chrono::Utc>,
}

pub struct ProcessManager {
processes: Mutex<HashMap<String, ManagedProcess>>,
max_concurrent: usize,
}

impl ProcessManager {
pub fn new(max_concurrent: usize) -> Self {
Self {
processes: Mutex::new(HashMap::new()),
max_concurrent,
}
}

pub async fn register(
&self,
id: String,
process: ManagedProcess,
) -> Result<(), String> {
let mut processes = self.processes.lock().await;

if processes.len() >= self.max_concurrent {
return Err(format!(
"Max concurrent processes ({}) reached",
self.max_concurrent
));
}

processes.insert(id, process);
Ok(())
}

pub async fn kill(&self, id: &str) -> Result<(), String> {
let mut processes = self.processes.lock().await;
if let Some(mut process) = processes.remove(id) {
process
.child
.kill()
.await
.map_err(|e| format!("Failed to kill process: {}", e))?;
}
Ok(())
}

pub async fn kill_all(&self) -> Vec<String> {
let mut processes = self.processes.lock().await;
let ids: Vec<String> = processes.keys().cloned().collect();

for (_, mut process) in processes.drain() {
let _ = process.child.kill().await;
}
ids
}

pub async fn count(&self) -> usize {
self.processes.lock().await.len()
}

pub async fn is_running(&self, id: &str) -> bool {
self.processes.lock().await.contains_key(id)
}

/// 좀비 프로세스 정리 (이미 종료된 프로세스 제거)
pub async fn cleanup_zombies(&self) -> Vec<String> {
let mut processes = self.processes.lock().await;
let mut zombies = Vec::new();

for (id, process) in processes.iter_mut() {
if let Ok(Some(_status)) = process.child.try_wait() {
zombies.push(id.clone());
}
}

for id in &zombies {
processes.remove(id);
}
zombies
}
}

6.2 앱 종료 시 프로세스 정리

// lib.rs - 앱 종료 훅
.on_event(|app, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
// 모든 자식 프로세스 종료
let app = app.clone();
tauri::async_runtime::block_on(async {
let state = app.state::<AppState>();
let killed = state.process_manager.lock().await.kill_all().await;
if !killed.is_empty() {
log::info!("Killed {} processes on exit", killed.len());
}
});
}
})

6.3 프론트엔드 프로세스 모니터링

// src/lib/stores/process-monitor.ts
import { writable, derived } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

interface ProcessInfo {
id: string;
sessionId: string;
status: 'running' | 'idle' | 'error' | 'terminated';
cpuUsage?: number;
memoryMb?: number;
startedAt: string;
}

export const processes = writable<Map<string, ProcessInfo>>(new Map());

export const activeProcessCount = derived(processes, ($p) =>
Array.from($p.values()).filter((p) => p.status === 'running').length
);

// 프로세스 상태 변경 이벤트 수신
export async function initProcessMonitor(): Promise<() => void> {
const unlisten = await listen<ProcessInfo>('process-status-changed', (event) => {
processes.update((map) => {
const updated = new Map(map);
if (event.payload.status === 'terminated') {
updated.delete(event.payload.id);
} else {
updated.set(event.payload.id, event.payload);
}
return updated;
});
});

return unlisten;
}

7. Sidecar로 CLI 번들링

Sidecar 기본 설정은 architecture.md 참조.

7.1 빌드 자동화 (build.rs)

// src-tauri/build.rs
fn main() {
// Sidecar 바이너리 존재 확인
let target = std::env::var("TARGET").unwrap();
let bin_name = format!("binaries/my-cli-{}", target);

#[cfg(target_os = "windows")]
let bin_name = format!("{}.exe", bin_name);

if !std::path::Path::new(&bin_name).exists() {
panic!(
"Sidecar binary not found: {}. \
Run 'scripts/build-sidecar.sh' first.",
bin_name
);
}

tauri_build::build();
}

7.2 CI에서 Sidecar 빌드

# .github/workflows/build-sidecar.yml
jobs:
build-sidecar:
strategy:
matrix:
include:
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

# Go CLI 예시
- uses: actions/setup-go@v5
with:
go-version: '1.22'

- name: Build sidecar
run: |
GOOS=$(echo ${{ matrix.target }} | cut -d- -f3)
GOARCH=$(echo ${{ matrix.target }} | cut -d- -f1 | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/')
go build -o src-tauri/binaries/my-cli-${{ matrix.target }} ./cmd/cli/

- uses: actions/upload-artifact@v4
with:
name: sidecar-${{ matrix.target }}
path: src-tauri/binaries/

8. 보안

공통 보안 가이드: ../common/security.md

Subprocess 보안 원칙

원칙설명
명시적 허용만Capability에 등록된 명령만 실행 가능
인자 검증validator 정규식으로 인자 제한
Sidecar 우선시스템 명령 대신 번들된 바이너리 사용
kill_on_dropRust Childkill_on_drop(true) 설정
출력 크기 제한stdout/stderr 버퍼 크기 제한 (DoS 방지)
환경 변수 격리자식 프로세스에 필요한 env만 전달

Capability 설정 (최소 권한)

{
"identifier": "shell-permissions",
"description": "CLI 실행 권한 (최소)",
"windows": ["main"],
"permissions": [
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "my-cli",
"cmd": "",
"args": true,
"sidecar": true
}
]
},
"shell:allow-stdin-write",
"shell:allow-kill"
]
}

환경 변수 격리

use std::process::Stdio;
use tokio::process::Command as TokioCommand;

let mut child = TokioCommand::new("my-cli")
.args(&["--format", "jsonl"])
// 필요한 환경 변수만 전달
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("MY_APP_SESSION", &session_id)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Spawn failed: {}", e))?;

Subprocess 관리 체크리스트

구현

  • Command API 또는 tokio::process 선택
  • stdout/stderr 스트리밍 처리 구현
  • JSON 라인 파서 구현 (불완전한 줄 버퍼링)
  • stdin 쓰기 기능 (대화형 CLI 시)
  • 세션 매니저 구현 (session_id 기반)
  • 프로세스 매니저 구현 (동시 실행 제한)

안정성

  • 프로세스 크래시 감지 + 자동 재시작
  • 타임아웃 처리 (응답 없는 프로세스 kill)
  • 좀비 프로세스 주기적 정리
  • 앱 종료 시 모든 자식 프로세스 kill
  • kill_on_drop(true) 설정

보안

  • Capability에 허용 명령 명시
  • 인자 validator 정규식 설정
  • Sidecar 바이너리 사용 (시스템 명령 직접 호출 최소화)
  • 환경 변수 격리 (env_clear() + 필요한 것만 전달)
  • stdout/stderr 버퍼 크기 제한