Skip to content

Commit caf7cef

Browse files
committed
fix(capture): 采集链路竞态治理与健壮性加固
- EndpointLoopbackProvider/audioCapture 启动路径加同步互斥锁,堵住守卫与状态翻转间的 await 空隙,防并发双启动 - video_record_stop 先同步取引用并置空再 await,双停/新旧录制交替时不再误 dispose 或崩溃 - screen_capture_start 防抖顶替旧请求时显式 settle 旧 Promise,避免渲染层 loading 态永久卡死 - VideoRecorder 写流监听 error 事件,磁盘故障时优雅停止并通知渲染层,不再未捕获异常崩溃主进程
1 parent 68dcd1b commit caf7cef

5 files changed

Lines changed: 115 additions & 66 deletions

File tree

‎client/electron/audio/endpointLoopbackProvider.ts‎

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -51,45 +51,53 @@ export class EndpointLoopbackProvider implements AudioSourceProvider {
5151
private readonly sink: AudioChunkSink;
5252
private capturing = false;
5353
private disposed = false;
54+
/** 启动中互斥锁:守卫与 capturing 置位间存在 await(listAudioSources)空隙,同步上锁防并发双启动 */
55+
private starting = false;
5456
private boundWindow: AudioProviderStartContext['window'] | null = null;
5557

5658
constructor(sink: AudioChunkSink) {
5759
this.sink = sink;
5860
}
5961

6062
async start(ctx: AudioProviderStartContext): Promise<void> {
61-
if (this.capturing || this.disposed) return;
62-
63-
// 解析音频源:未指定时自动取首个屏幕源
64-
let resolvedSourceId = ctx.sourceId;
65-
if (!resolvedSourceId) {
66-
const sources = await listAudioSources();
67-
if (sources.length === 0) {
68-
throw new Error('No audio source available');
63+
if (this.capturing || this.starting || this.disposed) return;
64+
65+
// 进入即同步上锁,finally 释放:堵住守卫与状态翻转间的 await 空隙
66+
this.starting = true;
67+
try {
68+
// 解析音频源:未指定时自动取首个屏幕源
69+
let resolvedSourceId = ctx.sourceId;
70+
if (!resolvedSourceId) {
71+
const sources = await listAudioSources();
72+
if (sources.length === 0) {
73+
throw new Error('No audio source available');
74+
}
75+
resolvedSourceId = sources[0].id;
76+
logger.info(`[EndpointLoopback] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
6977
}
70-
resolvedSourceId = sources[0].id;
71-
logger.info(`[EndpointLoopback] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
72-
}
7378

74-
// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
75-
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
76-
setPreferredDisplaySource(resolvedSourceId);
77-
78-
this.capturing = true;
79-
this.boundWindow = ctx.window;
80-
81-
logger.info(
82-
`[EndpointLoopback] 开始捕获, sourceId=${resolvedSourceId}, ` +
83-
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
84-
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
85-
);
86-
87-
// 通知渲染进程开始音频采集
88-
if (!ctx.window.isDestroyed()) {
89-
ctx.window.webContents.send('audio_capture_do_start', {
90-
sourceId: resolvedSourceId,
91-
options: ctx.options,
92-
});
79+
// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
80+
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
81+
setPreferredDisplaySource(resolvedSourceId);
82+
83+
this.capturing = true;
84+
this.boundWindow = ctx.window;
85+
86+
logger.info(
87+
`[EndpointLoopback] 开始捕获, sourceId=${resolvedSourceId}, ` +
88+
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
89+
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
90+
);
91+
92+
// 通知渲染进程开始音频采集
93+
if (!ctx.window.isDestroyed()) {
94+
ctx.window.webContents.send('audio_capture_do_start', {
95+
sourceId: resolvedSourceId,
96+
options: ctx.options,
97+
});
98+
}
99+
} finally {
100+
this.starting = false;
93101
}
94102
}
95103

‎client/electron/audioCapture.ts‎

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export class AudioCapture {
7979
private readonly onChunk: (chunk: AudioChunk) => void;
8080
private capturing = false;
8181
private disposed = false;
82+
/** 启动中互斥锁:守卫与 capturing 置位间存在 await 空隙,同步上锁防并发双启动 */
83+
private starting = false;
8284

8385
/** 当前活跃的 Provider */
8486
private provider: AudioSourceProvider | null = null;
@@ -127,40 +129,47 @@ export class AudioCapture {
127129
sourceId?: string,
128130
extras?: AudioCaptureStartExtras,
129131
): Promise<void> {
130-
if (this.capturing || this.disposed) return;
132+
if (this.capturing || this.starting || this.disposed) return;
131133

132-
const resolvedSourceId = sourceId ?? null;
133-
// 应急总闸关闭时能力恒为不可用,选源退回端点环回
134-
const processAvailable = isProcessLoopbackEnabled() && isProcessLoopbackAvailable();
135-
this.decision = selectAudioSource({
136-
capabilities: { processLoopbackAvailable: processAvailable },
137-
sourceId: resolvedSourceId,
138-
preference: extras?.preference,
139-
microphone: extras?.microphone,
140-
});
134+
// 进入即同步上锁,finally 释放:堵住守卫检查与状态翻转之间的 await 空隙,
135+
// 防止并发双启动导致 provider 被重复创建/降级链交叉触发
136+
this.starting = true;
137+
try {
138+
const resolvedSourceId = sourceId ?? null;
139+
// 应急总闸关闭时能力恒为不可用,选源退回端点环回
140+
const processAvailable = isProcessLoopbackEnabled() && isProcessLoopbackAvailable();
141+
this.decision = selectAudioSource({
142+
capabilities: { processLoopbackAvailable: processAvailable },
143+
sourceId: resolvedSourceId,
144+
preference: extras?.preference,
145+
microphone: extras?.microphone,
146+
});
141147

142-
logger.info(
143-
`[AudioCapture] 选源: ${this.decision.kind}(${this.decision.reason})` +
144-
`${this.decision.fallback ? `, 降级目标=${this.decision.fallback}` : ''}`,
145-
);
148+
logger.info(
149+
`[AudioCapture] 选源: ${this.decision.kind}(${this.decision.reason})` +
150+
`${this.decision.fallback ? `, 降级目标=${this.decision.fallback}` : ''}`,
151+
);
146152

147-
try {
148-
await this.startWithKind(this.decision.kind, win, resolvedSourceId);
149-
} catch (err) {
150-
const fallback = this.decision.fallback;
151-
if (!fallback) throw err;
153+
try {
154+
await this.startWithKind(this.decision.kind, win, resolvedSourceId);
155+
} catch (err) {
156+
const fallback = this.decision.fallback;
157+
if (!fallback) throw err;
152158

153-
const message = err instanceof Error ? err.message : String(err);
154-
logger.warn(`[AudioCapture] ${this.decision.kind} 启动失败(${message}),降级到 ${fallback}`);
155-
this.decision = {
156-
kind: fallback,
157-
reason: `${this.decision.kind} 启动失败后降级:${message}`,
158-
fallback: null,
159-
};
160-
await this.startWithKind(fallback, win, resolvedSourceId);
161-
}
159+
const message = err instanceof Error ? err.message : String(err);
160+
logger.warn(`[AudioCapture] ${this.decision.kind} 启动失败(${message}),降级到 ${fallback}`);
161+
this.decision = {
162+
kind: fallback,
163+
reason: `${this.decision.kind} 启动失败后降级:${message}`,
164+
fallback: null,
165+
};
166+
await this.startWithKind(fallback, win, resolvedSourceId);
167+
}
162168

163-
this.capturing = true;
169+
this.capturing = true;
170+
} finally {
171+
this.starting = false;
172+
}
164173
}
165174

166175
/** 按源类型创建并启动 Provider */

‎client/electron/mediaCaptureHandlers.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,20 @@ export function registerMediaCaptureHandlers(): void {
207207
if (!activeVideoRecorder) {
208208
return { success: false, error: 'No active recording' };
209209
}
210+
// 竞态防护:先同步取引用并置空,双停时第二次调用直接返回无活跃录制;
211+
// 也避免 await 期间新录制启动后被误 dispose
212+
const recorder = activeVideoRecorder;
213+
activeVideoRecorder = null;
210214
try {
211-
const filePath = await activeVideoRecorder.stopRecording();
212-
const finalStatus = activeVideoRecorder.status;
215+
const filePath = await recorder.stopRecording();
216+
const finalStatus = recorder.status;
213217
logger.info('[IPC] video_record_stop 已完成');
214-
activeVideoRecorder.dispose();
215-
activeVideoRecorder = null;
218+
recorder.dispose();
216219
return { success: true, filePath, finalStatus };
217220
} catch (err) {
218221
const message = err instanceof Error ? err.message : String(err);
219222
logger.error('[IPC] video_record_stop failed:', message);
220-
activeVideoRecorder?.dispose();
221-
activeVideoRecorder = null;
223+
recorder.dispose();
222224
return { success: false, error: message };
223225
}
224226
});

‎client/electron/screenCaptureHandlers.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ let activeCapture: ScreenCapture | null = null;
2727
let startDebounceTimer: ReturnType<typeof setTimeout> | null = null;
2828
const START_DEBOUNCE_MS = 500;
2929

30+
/**
31+
* 被防抖顶替的 screen_capture_start 调用的结算回调。
32+
* @ai-context 防抖替换旧请求时必须显式 settle 旧 Promise,否则旧调用的
33+
* invoke 永久 pending,渲染层若以 loading 态等待返回将永久卡死。
34+
*/
35+
let pendingStartResolve: ((result: { success: boolean }) => void) | null = null;
36+
3037
/** 单调递增会话标识,用于防止 stop 后残留的 debounce 重启采集 */
3138
let captureSessionToken = 0;
3239

@@ -45,18 +52,25 @@ export function registerScreenCaptureHandlers(): void {
4552
safeHandle(
4653
'screen_capture_start',
4754
async (event, options: ScreenCaptureOptions) => {
48-
// 防抖:500ms 内多次调用只响应最后一次
55+
// 防抖:500ms 内多次调用只响应最后一次——先结算被顶替的旧请求,
56+
// 避免旧 invoke 永久 pending 卡死渲染层 loading 态
4957
if (startDebounceTimer) {
5058
clearTimeout(startDebounceTimer);
5159
startDebounceTimer = null;
5260
}
61+
if (pendingStartResolve) {
62+
pendingStartResolve({ success: false });
63+
pendingStartResolve = null;
64+
}
5365

5466
// 记录本次会话 token,用于 debounce 回调时校验是否仍然有效
5567
const token = ++captureSessionToken;
5668

5769
return new Promise<{ success: boolean }>((resolve) => {
70+
pendingStartResolve = resolve;
5871
startDebounceTimer = setTimeout(() => {
5972
startDebounceTimer = null;
73+
pendingStartResolve = null;
6074

6175
// 如果在 debounce 期间调用了 stop(token 已变),放弃本次启动
6276
if (token !== captureSessionToken) {

‎client/electron/videoRecorder.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ export class VideoRecorder {
107107
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
108108
this.filePath = path.join(dir, `recording-${timestamp}.webm`);
109109
this.writeStream = fs.createWriteStream(this.filePath, { flags: 'a' });
110+
// 写流必须监听 error:WriteStream 的 'error' 事件无默认处理,
111+
// 磁盘故障/路径失效时会以未捕获异常直接崩溃主进程
112+
this.writeStream.on('error', (err) => {
113+
logger.error('[VideoRecorder] 文件写入失败:', err.message);
114+
this.recording = false;
115+
this.paused = false;
116+
this.stopStatusTimer();
117+
this.writeStream?.destroy();
118+
this.writeStream = null;
119+
if (this.boundWin && !this.boundWin.isDestroyed()) {
120+
this.boundWin.webContents.send('video_record_error', {
121+
message: `文件写入失败: ${err.message}`,
122+
filePath: this.filePath,
123+
});
124+
}
125+
});
110126
this.fileSizeBytes = 0;
111127
this.startTime = Date.now();
112128
this.pausedDurationMs = 0;

0 commit comments

Comments
 (0)