fix(embedded-mpv): serialize delayed recording commands

This commit is contained in:
4gray committed 2026-07-16 15:47:57 +02:00
1 parent 58287f4122
commit 42c31c8cda
3 files changed
+229 -28

No files matched your search

@@ -45,6 +45,11 @@ export type RecordingFeedback =
| RawRecordingFeedback
| TranslatedRecordingFeedback;
interface RecordingOutcome {
readonly feedback: RecordingFeedback | null;
readonly autoDismiss?: boolean;
}
interface PendingRecordingOperation {
readonly generation: number;
readonly kind: RecordingOperation;
@@ -54,6 +59,8 @@ interface PendingRecordingOperation {
readonly initialError: string | null;
readonly targetPath: string | null;
readonly baselineSnapshotIdentity: string | null;
observedOutcome: RecordingOutcome | null;
timeoutFeedback: RecordingFeedback | null;
commandSettled: boolean;
sawErrorClear: boolean;
}
@@ -85,7 +92,6 @@ export class EmbeddedMpvControlsRecording {
if (this.destroyed || this.pending) {
return;
}
const recording = context.session.recording;
const initialError = this.recordingError(context.session);
const kind = recording?.active
@@ -103,6 +109,8 @@ export class EmbeddedMpvControlsRecording {
baselineSnapshotIdentity: this.recordingSnapshotIdentity(
context.session
),
observedOutcome: null,
timeoutFeedback: null,
commandSettled: false,
sawErrorClear: initialError === null,
};
@@ -111,7 +119,6 @@ export class EmbeddedMpvControlsRecording {
() => this.handleAcknowledgementTimeout(generation),
RECORDING_ACK_TIMEOUT_MS
);
const command =
kind === RECORDING_OPERATION.START
? this.controller.startRecording(
@@ -139,16 +146,15 @@ export class EmbeddedMpvControlsRecording {
playbackIdentity !== pending.playbackIdentity
) {
this.cancelPending();
this.setFeedback(null);
return;
}
if (
!pending.commandSettled ||
this.recordingSnapshotIdentity(session) ===
pending.baselineSnapshotIdentity
pending.baselineSnapshotIdentity
) {
return;
}
const recording = session.recording;
if (!recording) {
return;
@@ -156,9 +162,11 @@ export class EmbeddedMpvControlsRecording {
const error = this.recordingError(session);
if (error) {
if (pending.sawErrorClear || error !== pending.initialError) {
this.completePending({
kind: RECORDING_FEEDBACK.RAW,
text: error,
this.observeOutcome(pending, {
feedback: {
kind: RECORDING_FEEDBACK.RAW,
text: error,
},
});
}
return;
@@ -167,23 +175,21 @@ export class EmbeddedMpvControlsRecording {
if (recording.active !== pending.expectedActive) {
return;
}
if (pending.kind === RECORDING_OPERATION.START) {
this.completePending(null);
this.observeOutcome(pending, { feedback: null });
return;
}
const targetPath = recording.targetPath ?? pending.targetPath;
this.completePending(
targetPath
this.observeOutcome(pending, {
feedback: targetPath
? {
kind: RECORDING_FEEDBACK.TRANSLATED,
key: RECORDING_TRANSLATION.SAVED_TO,
params: { path: targetPath },
}
: null,
Boolean(targetPath)
);
autoDismiss: Boolean(targetPath),
});
}
syncOwner(playbackIdentity: string | null, sessionId: string | null): void {
@@ -198,7 +204,6 @@ export class EmbeddedMpvControlsRecording {
if (this.ownerIdentity === nextIdentity) {
return;
}
this.ownerIdentity = nextIdentity;
this.setFeedback(null);
if (
@@ -211,17 +216,29 @@ export class EmbeddedMpvControlsRecording {
}
destroy(): void {
this.setFeedback(null);
this.destroyed = true;
this.cancelPending();
this.clearMessageTimer();
}
private handleAcknowledgementTimeout(generation: number): void {
const pending = this.pending;
let pending = this.pending;
if (!pending || pending.generation !== generation) {
return;
}
this.acknowledgementTimer = null;
this.reconcile(
this.controller.session(),
this.currentPlaybackIdentity()
);
pending = this.pending;
if (
!pending ||
pending.generation !== generation ||
pending.observedOutcome
) {
return;
}
const currentSession = this.controller.session();
const currentOwnerIdentity = JSON.stringify([
pending.playbackIdentity,
@@ -232,7 +249,7 @@ export class EmbeddedMpvControlsRecording {
this.ownerIdentity === currentOwnerIdentity
? this.recordingError(currentSession)
: null;
this.completePending({
const feedback: RecordingFeedback = {
...(error
? { kind: RECORDING_FEEDBACK.RAW, text: error }
: {
@@ -242,7 +259,13 @@ export class EmbeddedMpvControlsRecording {
? RECORDING_TRANSLATION.START_FAILED
: RECORDING_TRANSLATION.STOP_FAILED,
}),
});
};
pending.timeoutFeedback = feedback;
if (pending.commandSettled) {
this.completePending(feedback);
return;
}
this.setFeedback(feedback);
}
private markCommandSettled(generation: number): void {
@@ -255,6 +278,39 @@ export class EmbeddedMpvControlsRecording {
this.controller.session(),
this.currentPlaybackIdentity()
);
const reconciledPending = this.pending;
if (!reconciledPending || reconciledPending.generation !== generation) {
return;
}
if (reconciledPending.observedOutcome) {
this.completePending(
reconciledPending.observedOutcome.feedback,
reconciledPending.observedOutcome.autoDismiss
);
return;
}
if (reconciledPending.timeoutFeedback) {
this.completePending(reconciledPending.timeoutFeedback);
}
}
private observeOutcome(
pending: PendingRecordingOperation,
outcome: RecordingOutcome
): void {
if (this.pending !== pending || pending.observedOutcome) {
return;
}
pending.observedOutcome = outcome;
this.clearAcknowledgementTimer();
if (pending.commandSettled) {
this.completePending(outcome.feedback, outcome.autoDismiss);
} else if (
pending.timeoutFeedback ||
outcome.feedback?.kind === RECORDING_FEEDBACK.RAW
) {
this.setFeedback(outcome.feedback, outcome.autoDismiss);
}
}
private recordingSnapshotIdentity(
@@ -305,7 +361,6 @@ export class EmbeddedMpvControlsRecording {
if (!feedback || !autoDismiss) {
return;
}
const timerId = window.setTimeout(() => {
if (!this.destroyed && this.feedbackState() === feedback) {
this.feedbackState.set(null);
@@ -331,7 +386,6 @@ export class EmbeddedMpvControlsRecording {
}
}
}
export function resolveRecordingFeedback(
feedback: RecordingFeedback | null,
translate: TranslateService
@@ -221,20 +221,161 @@ describe('EmbeddedMpvControlsAdapter command/session ordering', () => {
expect(adapter.state().recording.message).toBeNull();
});
it('waits for a post-settlement acknowledgement when a stale response restores the baseline', async () => {
it.each(['start', 'stop'] as const)(
'serializes a delayed %s command and finalizes its stored acknowledgement',
async (operation) => {
const targetPath = '/recordings/live.ts';
const baselineRecording =
operation === 'start'
? { active: false }
: {
active: true,
startedAt: '2026-07-16T10:00:02.000Z',
};
const acknowledgedRecording =
operation === 'start'
? {
active: true,
targetPath,
startedAt: '2026-07-16T10:00:03.000Z',
}
: { active: false, targetPath };
const command = deferred<EmbeddedMpvSession['recording'] | null>();
const lateCommand = deferred<
EmbeddedMpvSession['recording'] | null
>();
controller.session.set(session({ recording: baselineRecording }));
if (operation === 'start') {
controller.startRecording
.mockReturnValueOnce(command.promise)
.mockReturnValueOnce(lateCommand.promise);
} else {
controller.stopRecording
.mockReturnValueOnce(command.promise)
.mockReturnValueOnce(lateCommand.promise);
}
adapter.commands.toggleRecording();
controller.session.set(
session({ recording: acknowledgedRecording })
);
jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1);
expect(adapter.state().recording.message).toBeNull();
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(
operation === 'start' ? 1 : 0
);
expect(controller.stopRecording).toHaveBeenCalledTimes(
operation === 'stop' ? 1 : 0
);
controller.session.set(session({ recording: baselineRecording }));
command.resolve(baselineRecording);
await flushPromises();
expect(adapter.state().recording.message).toBe(
operation === 'stop' ? `Saved to ${targetPath}` : null
);
adapter.commands.toggleRecording();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBe(
operation === 'start'
? 'Failed to start recording'
: 'Failed to stop recording'
);
controller.session.set(
session({ recording: acknowledgedRecording })
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(
operation === 'stop' ? `Saved to ${targetPath}` : null
);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(
operation === 'start' ? 2 : 0
);
expect(controller.stopRecording).toHaveBeenCalledTimes(
operation === 'stop' ? 2 : 0
);
controller.session.set(session({ recording: baselineRecording }));
lateCommand.resolve(baselineRecording);
await flushPromises();
}
);
it('discards a stored acknowledgement when the session changes before settlement', async () => {
const targetPath = '/recordings/live.ts';
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.session.set(session({ recording: { active: true } }));
controller.stopRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
controller.session.set(
session({ recording: { active: false, targetPath } })
);
TestBed.tick();
controller.session.set(
session({ id: 'session-2', recording: { active: false } })
);
command.resolve({ active: true });
await flushPromises();
expect(adapter.state().recording.message).toBeNull();
});
it('shows a pre-settlement recording error without unlocking the command', async () => {
const addonError = ' Addon rejected stream-record ';
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
controller.session.set(
session({
recording: {
active: true,
startedAt: '2026-07-16T10:00:02.000Z',
},
recording: { active: false, error: addonError },
})
);
TestBed.tick();
expect(adapter.state().recording.message).toBe(addonError);
jest.advanceTimersByTime(ACK_TIMEOUT_MS + 1);
expect(adapter.state().recording.message).toBe(addonError);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(1);
controller.session.set(session({ recording: { active: false } }));
command.resolve({ active: false });
await flushPromises();
expect(adapter.state().recording.message).toBe(addonError);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(2);
});
it('keeps an unacknowledged timed-out command serialized until its promise settles', async () => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
jest.advanceTimersByTime(ACK_TIMEOUT_MS);
expect(adapter.state().recording.message).toBe(
'Failed to start recording'
);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(1);
command.resolve({ active: false });
await flushPromises();
expect(adapter.state().recording.message).toBe(
'Failed to start recording'
);
adapter.commands.toggleRecording();
expect(controller.startRecording).toHaveBeenCalledTimes(2);
});
it('waits for a post-settlement acknowledgement when only baseline snapshots arrive', async () => {
const command = deferred<EmbeddedMpvSession['recording'] | null>();
controller.startRecording.mockReturnValue(command.promise);
adapter.commands.toggleRecording();
controller.session.set(session({ recording: { active: false } }));
command.resolve({ active: false });
await flushPromises();
@@ -378,6 +378,12 @@ describe('EmbeddedMpvControlsAdapter recording acknowledgement races', () => {
}
adapter.commands.toggleRecording();
const state = adapter.state;
const bufferedRecording =
operation === 'start'
? { active: false, error: 'Buffered failure' }
: { active: false, targetPath: '/recordings/live.ts' };
controller.session.set(session({ recording: bufferedRecording }));
TestBed.tick();
TestBed.resetTestingModule();
command.resolve(null);