fix(stalker): serialize edit discovery

This commit is contained in:
4gray committed 2026-08-09 09:59:30 +02:00
1 parent 3505eafdd1
commit b387fe2a55
9 files changed
+370 -54

No files matched your search

@@ -18,10 +18,16 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
};
const portalRepair = {
applyOverride: jest.fn((playlist: PlaylistMeta) => playlist),
fenceForPlaylistEdit: jest.fn(),
fenceForPlaylistEdit: jest.fn(() => Promise.resolve()),
releasePlaylistEdit: jest.fn(),
commitPlaylistEdit: jest.fn(),
};
const stalkerSession = {
beginEditDiscovery: jest.fn(async (playlist: PlaylistMeta) => ({
playlistId: playlist._id,
owner: Symbol('edit-fence'),
})),
cancelEditDiscovery: jest.fn(),
replaceSessionAfterEdit: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
@@ -193,6 +199,50 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
message: 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE',
});
expect(stalkerSession.cancelEditDiscovery).toHaveBeenCalled();
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
draft._id
);
});
it('does not start discovery until old authentication and repair work drain', async () => {
let finishSessionFence: (fence: {
playlistId: string;
owner: symbol;
}) => void = () => undefined;
let finishRepairFence: () => void = () => undefined;
stalkerSession.beginEditDiscovery.mockReturnValueOnce(
new Promise((resolve) => {
finishSessionFence = resolve;
})
);
portalRepair.fenceForPlaylistEdit.mockReturnValueOnce(
new Promise((resolve) => {
finishRepairFence = resolve;
})
);
discovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/portal.php',
isFullStalkerPortal: false,
});
const resolving = service.resolveConnection(draft);
await Promise.resolve();
expect(discovery.discover).not.toHaveBeenCalled();
finishSessionFence({
playlistId: draft._id,
owner: Symbol('edit-fence'),
});
await Promise.resolve();
expect(discovery.discover).not.toHaveBeenCalled();
finishRepairFence();
await resolving;
expect(discovery.discover).toHaveBeenCalledTimes(1);
});
it('replaces the active runtime playlist and session after a resolved full-portal edit', async () => {
@@ -227,7 +277,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
stalkerSessionIdentity: 'new-fingerprint',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
@@ -301,7 +352,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
isFullStalkerPortal: false,
stalkerToken: undefined,
stalkerSessionIdentity: undefined,
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(activePlaylist).toEqual(
expect.objectContaining({
@@ -373,6 +425,9 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
draft._id
);
expect(portalRepair.commitPlaylistEdit).not.toHaveBeenCalled();
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
expect(activePlaylist?.portalUrl).toBe(
'https://old.example.com/server/load.php'
@@ -421,7 +476,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
portalUrl: 'https://portal.example.com/server/load.php',
username: 'new-user',
stalkerToken: 'NEW_TOKEN',
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(
isActive ? 1 : 0
@@ -32,6 +32,10 @@ const STALKER_EDIT_ERROR_KEY_BY_KIND: Readonly<
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
type StalkerEditFence = Awaited<
ReturnType<StalkerSessionService['beginEditDiscovery']>
>;
@Injectable({ providedIn: 'root' })
export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylistConnectionEditor {
private readonly discovery = inject(StalkerPortalDiscoveryService);
@@ -39,6 +43,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
private readonly stalkerSession = inject(StalkerSessionService);
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly editFences = new Map<string, StalkerEditFence>();
async applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void> {
// Edit discovery is newer and more authoritative than a lazy repair
@@ -46,16 +51,29 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
// the resolved row could turn a credential-only A→A edit back into
// the repair's old A→B result.
const runtimePlaylist = this.toRuntimePlaylist(playlist);
this.portalRepair.fenceForPlaylistEdit(runtimePlaylist._id);
const persistedPlaylist =
await this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
const fence =
this.editFences.get(runtimePlaylist._id) ??
(await this.beginEditFence(runtimePlaylist));
try {
const persistedPlaylist =
await this.stalkerSession.replaceSessionAfterEdit(
runtimePlaylist,
fence
);
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
this.editFences.delete(runtimePlaylist._id);
if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) {
// The persistence result is the complete row merged by
// PlaylistsService, so backup-restored playback headers and other
// metadata absent from the form survive the active replacement.
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
if (
this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id
) {
// The persistence result is the complete row merged by
// PlaylistsService, so backup-restored playback headers and
// other metadata absent from the form survive replacement.
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
}
} catch (error) {
this.releaseEditFence(runtimePlaylist._id, fence);
throw error;
}
}
@@ -77,19 +95,31 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
stalkerSignature1: identity.signature1 ?? '',
stalkerSignature2: identity.signature2 ?? '',
};
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
identity,
{
credentials: {
username: playlist.username ?? '',
password: playlist.password ?? '',
},
}
const fence = await this.beginEditFence(
this.toRuntimePlaylist(normalizedPlaylist)
);
let outcome: Awaited<
ReturnType<StalkerPortalDiscoveryService['discover']>
>;
try {
outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
identity,
{
credentials: {
username: playlist.username ?? '',
password: playlist.password ?? '',
},
}
);
} catch (error) {
this.releaseEditFence(playlist._id, fence);
throw error;
}
if (outcome.status === 'unreachable') {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
message: this.translate.instant(
@@ -99,6 +129,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
}
if (outcome.status === 'auth-rejected') {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
message: this.buildAuthErrorMessage(outcome.error),
@@ -122,6 +153,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
}
if (!outcome.token) {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
message: this.translate.instant(
@@ -156,6 +188,41 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
};
}
private async beginEditFence(
playlist: Playlist
): Promise<StalkerEditFence> {
// Both fences are installed synchronously before either drain is
// awaited. No new authentication or lazy repair can start while the
// work that predates this Edit is settling.
const repairDrain = this.portalRepair.fenceForPlaylistEdit(
playlist._id
);
let fence: StalkerEditFence | undefined;
try {
fence = await this.stalkerSession.beginEditDiscovery(playlist);
await repairDrain;
this.editFences.set(playlist._id, fence);
return fence;
} catch (error) {
if (fence) {
this.stalkerSession.cancelEditDiscovery(fence);
}
this.portalRepair.releasePlaylistEdit(playlist._id);
throw error;
}
}
private releaseEditFence(
playlistId: string,
fence: StalkerEditFence
): void {
if (this.editFences.get(playlistId) === fence) {
this.editFences.delete(playlistId);
}
this.stalkerSession.cancelEditDiscovery(fence);
this.portalRepair.releasePlaylistEdit(playlistId);
}
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const headline = this.translate.instant(