From 294786afe1b6acc2603e05ce2ce40b835d938044 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 12 Nov 2026 11:30:04 +0900 Subject: [PATCH 1/2] fix(mcp): use idle timeout for kmsg_send instead of fixed cap Cold sends that open the chat via search take 10-16s on an M4 Mac mini, so the fixed 10s cap (18s with deep recovery) kills sends that are slow but still progressing. The runner only waited for exit and read stderr at the end, so it could not tell slow from stuck. - Read the child's stderr incrementally and track the last write time. - Add an optional idleTimeoutSec to KmsgSubprocessRunner.run(); when set, the run fails after that many seconds without stderr output, under the existing timeoutSec as a hard ceiling. When nil, behavior is unchanged. - kmsg_send always passes --trace-ax so the unbuffered [trace-ax] lines act as a heartbeat, and uses idle 12s / hard 30s. stderr is still only surfaced in the response when trace_ax was requested. Refs #25 Co-Authored-By: Claude Opus 5.5 --- Sources/kmsg/Commands/MCPServerCommand.swift | 50 ++++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index 43b92bb..7eb758c 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -32,6 +32,7 @@ private enum MCPStdioTransport { private final class MCPDataBuffer: @unchecked Sendable { private let lock = NSLock() private var data = Data() + private var lastWrite = Date() func store(_ newData: Data) { lock.lock() @@ -39,6 +40,19 @@ private final class MCPDataBuffer: @unchecked Sendable { lock.unlock() } + func append(_ chunk: Data) { + lock.lock() + data.append(chunk) + lastWrite = Date() + lock.unlock() + } + + func lastWriteDate() -> Date { + lock.lock() + defer { lock.unlock() } + return lastWrite + } + func load() -> Data { lock.lock() defer { lock.unlock() } @@ -57,7 +71,12 @@ private final class KmsgSubprocessRunner { } } - func run(_ arguments: [String], timeoutSec: TimeInterval) -> MCPCommandResult { + /// - Parameters: + /// - timeoutSec: Hard ceiling for the whole run. + /// - idleTimeoutSec: When set, the run is also killed after this many seconds + /// without stderr output. `--trace-ax` lines are written to stderr unbuffered, + /// so they act as a progress heartbeat. When nil, behavior is unchanged. + func run(_ arguments: [String], timeoutSec: TimeInterval, idleTimeoutSec: TimeInterval? = nil) -> MCPCommandResult { let start = Date() let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) @@ -90,7 +109,12 @@ private final class KmsgSubprocessRunner { stdoutSemaphore.signal() } DispatchQueue.global().async { - stderrData.store(stderrPipe.fileHandleForReading.readDataToEndOfFile()) + let handle = stderrPipe.fileHandleForReading + while true { + let chunk = handle.availableData + if chunk.isEmpty { break } + stderrData.append(chunk) + } stderrSemaphore.signal() } @@ -100,7 +124,19 @@ private final class KmsgSubprocessRunner { waitSemaphore.signal() } - let timedOut = waitSemaphore.wait(timeout: .now() + timeoutSec) == .timedOut + var timedOut = false + if let idleTimeoutSec { + while waitSemaphore.wait(timeout: .now() + 0.25) == .timedOut { + let now = Date() + if now.timeIntervalSince(start) > timeoutSec + || now.timeIntervalSince(stderrData.lastWriteDate()) > idleTimeoutSec { + timedOut = true + break + } + } + } else { + timedOut = waitSemaphore.wait(timeout: .now() + timeoutSec) == .timedOut + } if timedOut { process.terminate() @@ -614,9 +650,13 @@ private final class KmsgMCPServer { var command = ["send", chat, message] if deepRecovery { command.append("--deep-recovery") } if keepWindow { command.append("--keep-window") } - if traceAX { command.append("--trace-ax") } + // Always trace: stderr trace lines are the heartbeat for the idle timeout. + // stderr is still only surfaced in the response when traceAX was requested. + command.append("--trace-ax") - let run = runner.run(command, timeoutSec: deepRecovery ? 18.0 : 10.0) + // Cold opens via search measured 10.3-15.9s (n=29) with silent gaps up to ~8s, + // so a fixed 10s/18s cap is too tight; fail on 12s of silence instead. + let run = runner.run(command, timeoutSec: 30.0, idleTimeoutSec: 12.0) if run.timedOut { return errorPayload( code: "PROCESS_TIMEOUT", From 33d5ca36eb7823e82b0dfe465c686784475d4f09 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 12 Nov 2026 12:23:29 +0900 Subject: [PATCH 2/2] fix(mcp): apply idle timeout to kmsg_read and kmsg_send_image Every command that drives the KakaoTalk UI spends 5.5-7.8s before its first output (auth/window probing), so the fixed caps fail cold runs across the board, not just send. Measured with the Homebrew v1.260921.0 MCP server, 5 cold runs each: - kmsg_read: 15/15 PROCESS_TIMEOUT (8s cap, 15s with deep recovery, and 8s with chat_id) - kmsg_send_image: 5/5 PROCESS_TIMEOUT without deep recovery (12s cap); 16.5-18.0s with deep recovery, close to its 20s cap Use the same heartbeat approach as kmsg_send for read (including the CHAT_NOT_FOUND deep-recovery retry) and send-image, and share the limits as KmsgSubprocessRunner.uiIdleTimeoutSec (12s) and uiHardTimeoutSec (30s). Refs #25 Co-Authored-By: Claude Opus 5.5 --- Sources/kmsg/Commands/MCPServerCommand.swift | 41 +++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index 7eb758c..d9a7922 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -61,6 +61,12 @@ private final class MCPDataBuffer: @unchecked Sendable { } private final class KmsgSubprocessRunner { + /// Limits for commands that drive the KakaoTalk UI (read/send/send-image). + /// Cold runs measured 10-18s with silent gaps up to ~8s, so they fail on + /// 12s without stderr output (the `--trace-ax` heartbeat) under a 30s ceiling. + static let uiIdleTimeoutSec: TimeInterval = 12.0 + static let uiHardTimeoutSec: TimeInterval = 30.0 + let executablePath: String init() { @@ -537,10 +543,15 @@ private final class KmsgMCPServer { if deepRecovery { command.append("--deep-recovery") } if backgroundSafe { command.append("--background-safe") } if keepWindow { command.append("--keep-window") } - if traceAX { command.append("--trace-ax") } + // Always trace: stderr trace lines are the heartbeat for the idle timeout. + // stderr is still only surfaced in the response when traceAX was requested. + command.append("--trace-ax") - let timeoutSec = deepRecovery ? 15.0 : 8.0 - var first = runner.run(command, timeoutSec: timeoutSec) + var first = runner.run( + command, + timeoutSec: KmsgSubprocessRunner.uiHardTimeoutSec, + idleTimeoutSec: KmsgSubprocessRunner.uiIdleTimeoutSec + ) if first.timedOut { return errorPayload( @@ -559,7 +570,11 @@ private final class KmsgMCPServer { if code == "CHAT_NOT_FOUND" && !deepRecovery && !backgroundSafe { var retryCommand = command retryCommand.append("--deep-recovery") - let retry = runner.run(retryCommand, timeoutSec: 15.0) + let retry = runner.run( + retryCommand, + timeoutSec: KmsgSubprocessRunner.uiHardTimeoutSec, + idleTimeoutSec: KmsgSubprocessRunner.uiIdleTimeoutSec + ) if retry.returncode == 0 && !retry.timedOut { first = retry } else { @@ -654,9 +669,11 @@ private final class KmsgMCPServer { // stderr is still only surfaced in the response when traceAX was requested. command.append("--trace-ax") - // Cold opens via search measured 10.3-15.9s (n=29) with silent gaps up to ~8s, - // so a fixed 10s/18s cap is too tight; fail on 12s of silence instead. - let run = runner.run(command, timeoutSec: 30.0, idleTimeoutSec: 12.0) + let run = runner.run( + command, + timeoutSec: KmsgSubprocessRunner.uiHardTimeoutSec, + idleTimeoutSec: KmsgSubprocessRunner.uiIdleTimeoutSec + ) if run.timedOut { return errorPayload( code: "PROCESS_TIMEOUT", @@ -741,9 +758,15 @@ private final class KmsgMCPServer { var command = ["send-image", chat, imagePath] if deepRecovery { command.append("--deep-recovery") } if keepWindow { command.append("--keep-window") } - if traceAX { command.append("--trace-ax") } + // Always trace: stderr trace lines are the heartbeat for the idle timeout. + // stderr is still only surfaced in the response when traceAX was requested. + command.append("--trace-ax") - let run = runner.run(command, timeoutSec: deepRecovery ? 20.0 : 12.0) + let run = runner.run( + command, + timeoutSec: KmsgSubprocessRunner.uiHardTimeoutSec, + idleTimeoutSec: KmsgSubprocessRunner.uiIdleTimeoutSec + ) if run.timedOut { return errorPayload( code: "PROCESS_TIMEOUT",