Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { withDeadline } from '@/lib/capture/with-runtime-message-timeout'
import {
onRuntimeMessage,
RuntimeMessageType,
Expand Down Expand Up @@ -64,27 +65,6 @@ const startingSessions = new Map<string, Promise<void>>()
/** Sessions stopRecording() gave up waiting on startRecording() for. */
const stopRequested = new Set<string>()

/** Resolves with `fallback` after `ms` if `promise` hasn't settled by then. */
function withDeadline<T>(
promise: Promise<T>,
ms: number,
fallback: T,
): Promise<T> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
() => {
clearTimeout(timer)
resolve(fallback)
},
)
})
}

function bufferKey(sessionId: string): string {
return `capturePending:${sessionId}`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,34 @@
*/

/**
* A hung offscreen document (or a runtime-message handler that never
* resolves) must not block its caller forever — background/UI cleanup and
* setup flows need to make progress even when the offscreen side is wedged.
* Resolves with `fallback` after `ms` if `promise` hasn't settled by then —
* a hung offscreen document (or any promise that never resolves) must not
* block its caller forever.
*/
export function withRuntimeMessageTimeout<T>(
export function withDeadline<T>(
promise: Promise<T>,
ms: number,
): Promise<T | null> {
fallback: T,
): Promise<T> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), ms)
const timer = setTimeout(() => resolve(fallback), ms)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
() => {
clearTimeout(timer)
resolve(null)
resolve(fallback)
},
)
})
}

/** `withDeadline` specialized to the common "give up with null" case. */
export function withRuntimeMessageTimeout<T>(
promise: Promise<T>,
ms: number,
): Promise<T | null> {
return withDeadline(promise, ms, null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ export const CapturePage: FC = () => {
size="sm"
className="h-6 px-2 text-[11px]"
title="Immediately mark this meeting as stopped, skipping transcript finalization. Use this if it's stuck showing as live."
disabled={forceStopMeeting.isPending}
disabled={
forceStopMeeting.isPending &&
forceStopMeeting.variables === selectedSessionId
}
onClick={() =>
void forceStopMeeting.mutateAsync(selectedSessionId)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -928,8 +928,20 @@ function joinWithDeadline(promise: Promise<void>, ms: number): Promise<void> {
})
}

interface TeardownEntry {
promise: Promise<void>
/**
* Mutable so a joining caller that wants the remainder flushed can still
* get it even though it didn't start the task — checked right before the
* flush step runs. Doesn't help if a joiner arrives after that check has
* already passed, but narrows the window a lot compared to baking the
* driving caller's preference in for good.
*/
flushRemainder: boolean
}

/** In-flight teardownCaptureSession() calls, keyed by sessionId. */
const teardownInFlight = new Map<string, Promise<void>>()
const teardownInFlight = new Map<string, TeardownEntry>()

/**
* ASR drain / BYOK stop / session unregistration shared by stopMeetingCapture,
Expand All @@ -939,8 +951,7 @@ const teardownInFlight = new Map<string, Promise<void>>()
* stop looks stuck), and running ASR-drain/BYOK-stop/unregister twice
* concurrently for one session is the kind of thing that produces its own
* errors, not just wasted work. A caller racing in after the first started
* joins that same run rather than starting a second one — its own
* flushRemainder preference is ignored in that case.
* joins that same run rather than starting a second one.
*
* Only a *joining* caller is bounded (CAPTURE_TEARDOWN) — force-stop or a
* delete/fail racing in while a stop is already draining must not hang
Expand All @@ -956,12 +967,17 @@ async function teardownCaptureSession(
): Promise<void> {
const existing = teardownInFlight.get(sessionId)
if (existing) {
await joinWithDeadline(existing, TIMEOUTS.CAPTURE_TEARDOWN)
if (options.flushRemainder) existing.flushRemainder = true
await joinWithDeadline(existing.promise, TIMEOUTS.CAPTURE_TEARDOWN)
return
}
const task = (async () => {
const entry: TeardownEntry = {
flushRemainder: options.flushRemainder,
promise: Promise.resolve(),
}
entry.promise = (async () => {
await drainAsrQueue(sessionId)
if (options.flushRemainder) await flushAsrRemainder(sessionId)
if (entry.flushRemainder) await flushAsrRemainder(sessionId)
const reg = registeredSessions.get(sessionId)
if (reg?.byokSession) {
await reg.byokSession.stop().catch(() => undefined)
Expand All @@ -971,25 +987,32 @@ async function teardownCaptureSession(
await unregisterMicAsrSession(sessionId)
clearSpeakerTimeline(sessionId)
})()
teardownInFlight.set(sessionId, task)
teardownInFlight.set(sessionId, entry)
try {
await task
await entry.promise
} finally {
if (teardownInFlight.get(sessionId) === task) {
if (teardownInFlight.get(sessionId) === entry) {
teardownInFlight.delete(sessionId)
}
}
}

/** DB write only — see announceCaptureSessionStopped for the client-facing half. */
function writeCaptureSessionStoppedStatus(sessionId: string): void {
sqlite()
/**
* DB write only — see announceCaptureSessionStopped for the client-facing
* half. Guarded: never overwrites a session already finalized by a
* concurrent stop/force-stop/fail (see teardownCaptureSession's doc comment
* for why these can race for the same session). Returns whether this call
* actually won the write, so callers know whether to announce it.
*/
function writeCaptureSessionStoppedStatus(sessionId: string): boolean {
const result = sqlite()
.prepare(
`UPDATE capture_sessions
SET status = 'stopped', ended_at = ?
WHERE id = ?`,
WHERE id = ? AND status NOT IN ('stopped', 'error')`,
)
.run(Date.now(), sessionId)
return result.changes > 0
}

/**
Expand Down Expand Up @@ -1030,8 +1053,13 @@ export async function stopMeetingCapture(
// session before the status write below, so it would still report the
// pre-stop status.
await indexMeetingCapture(sessionId)
writeCaptureSessionStoppedStatus(sessionId)
announceCaptureSessionStopped(sessionId)
// Guarded: a concurrent forceStopMeetingCapture (the whole reason it
// exists is to race a stuck stop like this one) may have already
// finalized this session while teardown above was running. Don't
// overwrite its ended_at or re-announce if so.
if (writeCaptureSessionStoppedStatus(sessionId)) {
announceCaptureSessionStopped(sessionId)
}
return getCaptureSession(sessionId)
}

Expand All @@ -1057,7 +1085,12 @@ export async function forceStopMeetingCapture(
return session
}

writeCaptureSessionStoppedStatus(sessionId)
// Guarded: a concurrent stop/fail may have already finalized this session
// between the status check above and this write. If so, it already owns
// teardown/indexing/announcing — nothing left for force-stop to do.
if (!writeCaptureSessionStoppedStatus(sessionId)) {
return getCaptureSession(sessionId)
}

void (async () => {
try {
Expand Down Expand Up @@ -1159,18 +1192,23 @@ export async function failMeetingCapture(
if (!session) return null
const endedAt = Date.now()
const title = session.title ?? errorMessage.slice(0, 120)
sqlite()
// Guarded: a concurrent stop/force-stop may have already finalized this
// session (e.g. a late-surfacing upload/ASR error racing a successful
// stop) — don't clobber a session the user already saw stop cleanly.
const result = sqlite()
.prepare(
`UPDATE capture_sessions
SET status = 'error', ended_at = ?, title = ?
WHERE id = ?`,
WHERE id = ? AND status NOT IN ('stopped', 'error')`,
)
.run(endedAt, title, sessionId)
publishCaptureEvent(sessionId, {
type: 'status',
sessionId,
status: 'error',
})
if (result.changes > 0) {
publishCaptureEvent(sessionId, {
type: 'status',
sessionId,
status: 'error',
})
}
return getCaptureSession(sessionId)
}

Expand Down
Loading