Skip to content

Commit dddc37a

Browse files
committed
fix(macos): retain capture cleanup and diagnose system audio conflicts
1 parent fa430e3 commit dddc37a

11 files changed

Lines changed: 241 additions & 12 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Restore the macOS cancelled-recorder cleanup protections alongside the delayed startup-status UI, so an update built from the current mainline includes both fixes.
13+
- Distinguish microphone startup failure from a genuinely short recording. On startup timeout, report a possible system-audio capture conflict only when the specifically observed helper is present; this advisory never terminates other applications.
14+
1015
## [5.0.8] - 2026-09-13
1116

1217
### Fixed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# macOS microphone startup: verified failure and recovery boundary
2+
3+
On 2026-09-13 a macOS 26.5 machine running the Apple-signed local 5.0.8 build failed to start recording repeatedly. The input method, Settings app, and recorder all passed code-signature verification and had the audio-input entitlement. The actual input-method microphone request was approved by TCC (authValue=2, authReason=2). A Settings microphone smoke test reported authorization=authorized, success=false, waveform_blocks=0 after the eight-second startup deadline.
4+
5+
The recorder thread was waiting in AudioOutputUnitStart / AudioDeviceStart / HALB_IOThread::StartAndWaitForState. An independent AVAudioRecorder probe reported authorized access and then failed to return from recordForDuration before its ten-second external deadline. No other process was reported as actively recording by the public CoreAudio process-list API at the time checked.
6+
7+
A later HAL StartIO/Running message emitted while the cancelled client was being torn down is not evidence that microphone samples were captured. In this incident the same teardown reported zero frames. Earlier explanations that treated this as proof of ordinary multi-second DSP wake latency were too strong. The current evidence places the stall below the app's permission check, but does not establish which component originally caused it.
8+
9+
The app previously displayed “listening” before receiving audio and called the no-audio case “recording too short”. Cancellation sent SIGTERM and followed it with SIGKILL after 250 ms. The new behavior only shows listening after real PCM, gives an explicit startup-failure message otherwise, and allows cancelled startup to unwind without an early forced kill. The existing recorder startup watchdog is eight seconds; a nine-second parent cleanup deadline remains as a final bound. A new recording cannot overlap a cancelled helper's cleanup.
10+
11+
Tests cover UI timing classification and an artificial recorder that needs 600 ms to finish cancellation cleanup. These are regression tests, not evidence that the system-level recurrence is permanently solved. A main-run-loop pumping experiment did not restore capture in the failing state and was not included in the fix.
12+
13+
Restarting coreaudiod is a recovery operation, not a proven permanent fix. It requires normal user administrator authorization and interrupts audio across applications. Do not reset microphone permissions, replace Apple signatures with ad-hoc signatures, disable platform security, or repeatedly kill unrelated applications as a substitute for diagnosis. Verify recovery through real PCM and the signed Settings microphone smoke test, not only a green permission toggle or device enumeration.
14+
15+
## Recovery verification for this incident
16+
17+
The user-authorized coreaudiod restart succeeded. Before deploying the lifecycle patch, the same installed 5.0.8 recorder completed three one-second tests in 1.375, 1.398 and 1.411 seconds, producing 51,840, 51,840 and 53,760 frames at 48 kHz. The independent AVAudioRecorder probe captured 48,000 frames; the signed Settings smoke captured 73,920 frames across 77 waveform blocks. The successful recovery therefore predates this patch and cannot be credited to the patch. Test WAVs were removed.
18+
19+
The application patch passed all 11 macOS CTest cases and repository native contracts, including the new cancellation-cleanup test. A long-idle recurrence test is still needed before drawing any conclusion about prevention.
20+
21+
## Later recurrence: system-audio capture component comparison
22+
23+
On 2026-09-13 the installed input method was replaced by a mainline build from `fa430e3` (delayed startup-status UI), which did not include the separate `895c86e` cancellation-cleanup patch. This change integrates both code paths, rather than attributing the later failure to a test of the combined version.
24+
25+
During the later failure, an independently signed AVAudioRecorder probe reported authorized access but never returned from recordForDuration before its 10-second process deadline. The system audio daemon PID was 89799. At 02:53, terminating only the running `/Applications/ChatGPT.app/Contents/Resources/native/system-audio-spectrum` helper restored the same native probe: it captured 48,000 frames. The main ChatGPT application, the audio daemon, microphone permissions, and VocoType binaries were not restarted or changed for this comparison. The daemon PID remained 89799.
26+
27+
The existing VocoType recorder then captured 52,800 frames at 48 kHz in each of two one-second tests (1.471 and 1.497 seconds wall time). The existing VocoType recorder then captured 52,800 frames at 48 kHz in each of two one-second tests (1.471 and 1.497 seconds wall time). The existing VocoType recorder then captured 52,800 frames at 48 kHz in each of two one-second tests (1.471 and 1.497 seconds wall time). The existing VocoType recordehis occurrence.
28+
29+
This is evidence for a system-audio capture interaction, not proof that every startup failure has this cause or that the daemon can never fail independently. The helper was not re-enabled for a reverse reproduction. Its presence on timeout is reported only as a possible conflict. The recorder must never terminate another application's processes automatically. The component may reappear when its owning application starts system-audio capture again.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#pragma once
2+
#include <string>
3+
#include <string_view>
4+
5+
namespace vocotype::desktop {
6+
// Presence is only a troubleshooting hint, never proof of a causal conflict.
7+
// Do not terminate another application's processes from the recorder.
8+
inline bool is_known_system_audio_capture_helper(std::string_view path) {
9+
return path.ends_with("/ChatGPT.app/Contents/Resources/native/system-audio-spectrum");
10+
}
11+
inline std::string microphone_startup_error(bool helper_detected) {
12+
if (helper_detected)
13+
return "麦克风启动超过 8 秒;检测到 ChatGPT 音频采集组件可能冲突,请关闭该应用的系统音频采集或退出该应用后重试";
14+
return "麦克风启动超过 8 秒,未收到音频;请检查输入设备及其他音频应用后重试";
15+
}
16+
} // namespace vocotype::desktop

src/desktop/src/audio.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,13 @@ void AudioCapture::run(std::atomic_bool &stop, const BlockCallback &callback) {
657657
"cannot open microphone");
658658
stream_ = stream;
659659
try {
660+
// A key release may have arrived while device setup was in progress.
661+
// Do not start new microphone I/O after cancellation.
662+
if (stop.load(std::memory_order_acquire)) {
663+
check(Pa_CloseStream(stream), "cannot close cancelled microphone");
664+
stream_ = nullptr;
665+
return;
666+
}
660667
check(Pa_StartStream(stream), "cannot start microphone");
661668
std::vector<std::int16_t> interleaved(
662669
static_cast<std::size_t>(frames) *

src/desktop/src/audio_recorder_main.cpp

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
#include "vocotype/desktop/audio_startup_diagnostics.hpp"
2+
#ifdef __APPLE__
3+
#include <libproc.h>
4+
#endif
15
#include "vocotype/desktop/audio.hpp"
26
#include "vocotype/desktop/config.hpp"
37
#include "vocotype/desktop/ipc.hpp"
@@ -69,6 +73,32 @@ Options parse(int argc, char **argv) {
6973
return options;
7074
}
7175
} // namespace
76+
#ifdef __APPLE__
77+
namespace {
78+
bool known_system_audio_capture_helper_present() noexcept {
79+
try {
80+
constexpr int kMaxProcessBytes = 1024 * 1024;
81+
const int bytes = proc_listpids(PROC_ALL_PIDS, 0, nullptr, 0);
82+
if (bytes <= 0 || bytes > kMaxProcessBytes) return false;
83+
std::vector<pid_t> pids(static_cast<std::size_t>(bytes) / sizeof(pid_t) + 64);
84+
const int received = proc_listpids(PROC_ALL_PIDS, 0, pids.data(),
85+
static_cast<int>(pids.size() * sizeof(pid_t)));
86+
if (received <= 0) return false;
87+
const auto count = std::min(pids.size(), static_cast<std::size_t>(received) / sizeof(pid_t));
88+
for (std::size_t index = 0; index < count; ++index) {
89+
if (pids[index] <= 0) continue;
90+
char path[PROC_PIDPATHINFO_MAXSIZE]{};
91+
if (proc_pidpath(pids[index], path, sizeof(path)) > 0 &&
92+
vocotype::desktop::is_known_system_audio_capture_helper(path)) return true;
93+
}
94+
} catch (...) {
95+
// Diagnostics must never prevent the bounded recorder exit.
96+
}
97+
return false;
98+
}
99+
} // namespace
100+
#endif
101+
72102
int main(int argc, char **argv) {
73103
try {
74104
const Options options = parse(argc, argv);
@@ -292,16 +322,18 @@ int main(int argc, char **argv) {
292322
// not sufficient here: a timed recording may request stop while the
293323
// capture thread is still blocked inside AudioDeviceStart.
294324
std::thread([&first_audio_block, &capture_finished] {
295-
// A built-in microphone that has been idle for a long time can take just
296-
// over five seconds to leave its deep CoreAudio power state on macOS 26.
297-
// Keep enough margin to avoid killing the recorder exactly as the HAL
298-
// reports the device Running, while still bounding genuine startup hangs.
325+
// Bound a missing first PCM block. A later HAL "Running" log during
326+
// process cancellation is not evidence that audio capture succeeded,
327+
// nor proof of normal device wake-up latency.
299328
constexpr auto kMicrophoneStartupTimeout = std::chrono::seconds(8);
300329
std::this_thread::sleep_for(kMicrophoneStartupTimeout);
301330
if (!first_audio_block.load(std::memory_order_acquire) &&
302331
!capture_finished.load(std::memory_order_acquire)) {
332+
const bool helper_detected = known_system_audio_capture_helper_present();
303333
emit({{"type", "error"},
304-
{"error", "麦克风启动超过 8 秒(CoreAudio 未返回音频);请重试,必要时重启系统音频服务"}});
334+
{"error_code", "microphone_start_timeout"},
335+
{"system_audio_capture_hint", helper_detected},
336+
{"error", vocotype::desktop::microphone_startup_error(helper_detected)}});
305337
std::_Exit(2);
306338
}
307339
}).detach();

src/desktop/src/recorder_process.cpp

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ void RecorderProcess::start(const std::string &executable,
5454
EventCallback callback) {
5555
if (running())
5656
throw std::runtime_error("recorder is already running");
57+
#ifdef __APPLE__
58+
// Do not overlap another HAL start with a cancelled operation still unwinding.
59+
// This gate never records while idle and clears when the old child is reaped.
60+
static std::mutex launch_mutex;
61+
static std::weak_ptr<State> previous_capture;
62+
std::lock_guard launch_lock(launch_mutex);
63+
if (const auto previous = previous_capture.lock()) {
64+
std::lock_guard previous_lock(previous->mutex);
65+
if (previous->cancel_requested && !previous->finished)
66+
throw std::runtime_error("麦克风仍在清理上一段录音,请稍后重试");
67+
}
68+
#endif
5769

5870
int input_pipe[2]{};
5971
int output_pipe[2]{};
@@ -91,6 +103,9 @@ void RecorderProcess::start(const std::string &executable,
91103
state->stdin_fd = input_pipe[1];
92104
state->stdout_fd = output_pipe[0];
93105
state_ = state;
106+
#ifdef __APPLE__
107+
previous_capture = state;
108+
#endif
94109

95110
std::thread([state, callback = std::move(callback)] {
96111
int output_fd = -1;
@@ -201,9 +216,19 @@ void RecorderProcess::cancel_async() {
201216
std::remove(audio_path.c_str());
202217
if (child_pid > 0) {
203218
std::thread([state, child_pid] {
204-
std::this_thread::sleep_for(std::chrono::milliseconds(250));
205-
std::lock_guard lock(state->mutex);
206-
if (!state->finished && state->pid == child_pid)
219+
// Return to the UI immediately, but let a cancelled CoreAudio start
220+
// finish its own shutdown. Killing it after 250 ms cuts through HAL
221+
// initialization. The recorder's 8-second startup watchdog remains the
222+
// primary bound; this is a final fallback, not extra recording time.
223+
#ifdef __APPLE__
224+
constexpr auto grace = std::chrono::seconds(9);
225+
#else
226+
constexpr auto grace = std::chrono::milliseconds(250);
227+
#endif
228+
std::unique_lock lock(state->mutex);
229+
if (!state->changed.wait_for(lock, grace, [&] {
230+
return state->finished || state->pid != child_pid;
231+
}))
207232
kill(child_pid, SIGKILL);
208233
}).detach();
209234
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#include "vocotype/desktop/audio_startup_diagnostics.hpp"
2+
#include <iostream>
3+
#include <stdexcept>
4+
using namespace vocotype::desktop;
5+
int main() {
6+
try {
7+
if (!is_known_system_audio_capture_helper("/Applications/ChatGPT.app/Contents/Resources/native/system-audio-spectrum"))
8+
throw std::runtime_error("known helper not recognized");
9+
for (const auto path : {"", "/tmp/system-audio-spectrum", "/Applications/Other.app/Contents/Resources/native/system-audio-spectrum", "/Applications/ChatGPT.app/Contents/Resources/native/system-audio-spectrum-other"})
10+
if (is_known_system_audio_capture_helper(path)) throw std::runtime_error("unrelated process matched");
11+
if (microphone_startup_error(true).find("可能冲突") == std::string::npos)
12+
throw std::runtime_error("presence incorrectly described as proof");
13+
if (microphone_startup_error(false).find("ChatGPT") != std::string::npos)
14+
throw std::runtime_error("specific component blamed without detection");
15+
std::cout << "PASS specific, advisory-only audio startup diagnostics\n";
16+
return 0;
17+
} catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 1; }
18+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#include <csignal>
2+
#include <cstdlib>
3+
#include <chrono>
4+
#include <fstream>
5+
#include <iostream>
6+
#include <thread>
7+
namespace { volatile std::sig_atomic_t cancelled = 0; void stop(int) { cancelled = 1; } }
8+
int main() {
9+
std::signal(SIGTERM, stop);
10+
std::cout << "{\"type\":\"test_ready\"}\n" << std::flush;
11+
while (!cancelled) std::this_thread::sleep_for(std::chrono::milliseconds(5));
12+
// Model a driver unwinding an already in-flight startup. No microphone is
13+
// opened by this test helper. 250-ms SIGKILL must not interrupt cleanup.
14+
std::this_thread::sleep_for(std::chrono::milliseconds(600));
15+
const char *path = std::getenv("VOCOTYPE_TEST_CLEANUP_MARKER");
16+
if (!path) return 2;
17+
std::ofstream marker(path); marker << "cleaned\n";
18+
return marker ? 0 : 3;
19+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#include "vocotype/desktop/recorder_process.hpp"
2+
#include <atomic>
3+
#include <chrono>
4+
#include <cstdlib>
5+
#include <filesystem>
6+
#include <fstream>
7+
#include <iostream>
8+
#include <stdexcept>
9+
#include <thread>
10+
#include <unistd.h>
11+
int main(int argc, char **argv) {
12+
using namespace std::chrono_literals;
13+
if (argc != 2) return 2;
14+
char pattern[] = "/tmp/vocotype-cancel-cleanup-XXXXXX";
15+
const int fd = mkstemp(pattern);
16+
if (fd < 0) return 3;
17+
close(fd);
18+
struct Cleanup { const char *path; ~Cleanup() { std::remove(path); unsetenv("VOCOTYPE_TEST_CLEANUP_MARKER"); } } cleanup{pattern};
19+
setenv("VOCOTYPE_TEST_CLEANUP_MARKER", pattern, 1);
20+
try {
21+
std::atomic_bool ready{false};
22+
vocotype::desktop::RecorderProcess recorder;
23+
recorder.start(argv[1], [&](const std::string &type, const std::string &) {
24+
if (type == "test_ready") ready.store(true);
25+
});
26+
const auto deadline = std::chrono::steady_clock::now() + 2s;
27+
while (!ready.load() && std::chrono::steady_clock::now() < deadline)
28+
std::this_thread::sleep_for(5ms);
29+
if (!ready.load()) throw std::runtime_error("fake recorder never became ready");
30+
const auto started = std::chrono::steady_clock::now();
31+
recorder.cancel_async();
32+
if (std::chrono::steady_clock::now() - started > 200ms)
33+
throw std::runtime_error("cancel blocked the UI");
34+
vocotype::desktop::RecorderProcess overlapping;
35+
bool overlap_blocked = false;
36+
try { overlapping.start(argv[1], {}); }
37+
catch (const std::exception &) { overlap_blocked = true; }
38+
if (!overlap_blocked) throw std::runtime_error("new HAL start overlapped cancelled cleanup");
39+
while (recorder.running() && std::chrono::steady_clock::now() - started < 2s)
40+
std::this_thread::sleep_for(10ms);
41+
if (recorder.running()) throw std::runtime_error("cancelled recorder was not reaped");
42+
std::ifstream marker(pattern); std::string text; std::getline(marker, text);
43+
if (text != "cleaned") throw std::runtime_error("startup cleanup was interrupted by early SIGKILL");
44+
std::cout << "PASS cancellation returns immediately and allows startup cleanup\n";
45+
} catch (const std::exception &e) {
46+
std::cerr << "FAIL " << e.what() << '\n'; return 1;
47+
}
48+
}

src/integrations/macos/CMakeLists.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,24 @@ set_target_properties(VocoTypeSettings PROPERTIES
272272
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/SettingsInfo.plist
273273
XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED NO
274274
)
275+
276+
if(BUILD_TESTING)
277+
add_executable(vocotype-fake-startup-cleanup-recorder
278+
../../desktop/tests/fake_startup_cleanup_recorder.cpp)
279+
target_compile_features(vocotype-fake-startup-cleanup-recorder PRIVATE cxx_std_20)
280+
add_executable(vocotype-recorder-cancel-cleanup-tests
281+
../../desktop/tests/recorder_cancel_cleanup_tests.cpp)
282+
target_link_libraries(vocotype-recorder-cancel-cleanup-tests PRIVATE vocotype_macos_base)
283+
add_test(NAME vocotype-macos-recorder-cancel-cleanup
284+
COMMAND vocotype-recorder-cancel-cleanup-tests
285+
$<TARGET_FILE:vocotype-fake-startup-cleanup-recorder>)
286+
set_tests_properties(vocotype-macos-recorder-cancel-cleanup PROPERTIES TIMEOUT 15)
287+
endif()
288+
289+
if(BUILD_TESTING)
290+
add_executable(vocotype-audio-startup-diagnostics-tests
291+
../../desktop/tests/audio_startup_diagnostics_tests.cpp)
292+
target_include_directories(vocotype-audio-startup-diagnostics-tests PRIVATE ../../desktop/include)
293+
target_compile_features(vocotype-audio-startup-diagnostics-tests PRIVATE cxx_std_20)
294+
add_test(NAME vocotype-macos-audio-startup-diagnostics COMMAND vocotype-audio-startup-diagnostics-tests)
295+
endif()

0 commit comments

Comments
 (0)