From bb4a3ec3e0e93e3f5729ed9059c6429cef3d6552 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 13:58:23 +0900 Subject: [PATCH] fix: refresh the retained on-air scene --- .../Interop/DynamicK3dSession.cs | 47 ++- .../DynamicK3dSessionTests.cs | 285 +++++++++++++++++- 2 files changed, 300 insertions(+), 32 deletions(-) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 18b7905..107b5ce 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -550,18 +550,33 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession var engine = _engine!; var player = _player!; var outputChannel = _outputChannel; - object? playingScene = null; + // Tornado2 can return an incomplete GetPlayingScene proxy. The scene loaded, + // prepared, and promoted by Play is the only trusted reference for an in-place + // refresh, and it remains retained until the normal take-out/unload lifecycle. + if (_preparedScene is not null) + { + throw new InvalidOperationException( + "An on-air update cannot run while another scene is prepared."); + } + + var currentScene = ReusableScene(_currentScene, cue.SceneName) + ?? throw new InvalidOperationException( + "No matching retained on-air scene is available."); + if (SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0) + { + throw new InvalidOperationException( + "An on-air update cannot run while scene playback is pending."); + } + var transactionStarted = false; try { - playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex); - foreach (var mutation in cue.Mutations ?? []) { if (IsBeforeTransactionSceneSetupMutation(mutation)) { - ApplySceneSetupMutation(playingScene, mutation); + ApplySceneSetupMutation(currentScene.Value, mutation); } } @@ -570,7 +585,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession foreach (var field in cue.Fields ?? []) { - ApplyField(playingScene, field); + ApplyField(currentScene.Value, field); } foreach (var mutation in cue.Mutations ?? []) @@ -580,11 +595,11 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession // explicit in-transaction ordering. if (!IsBeforeTransactionSceneSetupMutation(mutation)) { - ApplyTransactionMutation(playingScene, mutation); + ApplyTransactionMutation(currentScene.Value, mutation); } } - Invoke(playingScene, "QueryVariables"); + Invoke(currentScene.Value, "QueryVariables"); if (outputChannel.HasValue) { Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault()); @@ -595,20 +610,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } transactionStarted = false; - Invoke(player, "Prepare", layoutIndex, playingScene); + Invoke(player, "Prepare", layoutIndex, currentScene.Value); InvokeTrackedPlay(layoutIndex); - - // GetPlayingScene can return the same RCW or another RCW for the same COM - // scene. An in-place page update must never retire/unload the active scene. - if (_currentScene is null) - { - _currentScene = new SceneReference(playingScene, cue.SceneName); - playingScene = null; - } - else if (ReferenceEquals(_currentScene.Value, playingScene)) - { - playingScene = null; - } } catch { @@ -626,10 +629,6 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession throw; } - finally - { - ReleaseComObject(playingScene); - } } public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope) diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index acb26b5..61bcb16 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -458,7 +458,7 @@ public sealed class DynamicK3dSessionTests } [Fact] - public async Task UpdateOnAir_UsesPlayingSceneAndAppliesOnlyBeforeTransactionBackgroundSetup() + public async Task UpdateOnAir_ReusesRetainedSceneAndIgnoresInvalidPlayingSceneProxy() { using var scenes = TemporarySceneDirectory.Create( "test-scene.t2s", @@ -484,7 +484,7 @@ public sealed class DynamicK3dSessionTests ])); var log = new FakeComLog(); var scene = new FakeScene(log, "test-scene"); - var player = new FakePlayer(log) { PlayingScene = scene }; + var player = new FakePlayer(log) { PlayingScene = new object() }; var activator = new FakeActivator(log, new FakeEngine(log, player, scene)); await using var dispatcher = new StaDispatcher(capacity: 2); var updateStart = 0; @@ -514,7 +514,6 @@ public sealed class DynamicK3dSessionTests Assert.Equal( new[] { - "GetPlayingScene:10", "UseBackground:0", "SetBackgroundTexture:background.vrv", "SetBackgroundVideo:background.vrv:4:1", @@ -531,20 +530,274 @@ public sealed class DynamicK3dSessionTests "Play:10" }, updateCalls); + Assert.Equal(1, updateCalls.Count(call => call == "Play:10")); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); Assert.DoesNotContain(updateCalls, call => call.StartsWith("LoadScene:", StringComparison.Ordinal) || call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal)); } [Fact] - public async Task UpdateOnAir_CallbackSession_TracksItsPlayUntilMatchingCallback() + public async Task UpdateOnAir_MismatchedRetainedSceneFailsBeforeAnySdkMutation() + { + using var scenes = TemporarySceneDirectory.Create( + "5001.t2s", + "5006.t2s", + "Video\\background.vrv"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var initialCue = SceneCue(options, "5001"); + var mismatchedCue = options.ResolveCue(new PlayoutCue( + "5006.t2s", + "5006", + [new PlayoutField("headline", "must not be sent", true)], + Mutations: + [ + new PlayoutUseBackground(false), + new PlayoutSetBackgroundTexture("Video/background.vrv") + ])); + var log = new FakeComLog(); + var retainedScene = new FakeScene(log, "5001"); + var player = new FakePlayer(log) { PlayingScene = new object() }; + var activator = new FakeActivator( + log, + new FakeEngine(log, player, retainedScene)); + await using var dispatcher = new StaDispatcher(capacity: 2); + string[] updateCalls = []; + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + session.Prepare(initialCue, options.LayoutIndex); + session.Play(options.LayoutIndex); + + var updateStart = log.Names.Length; + Assert.Throws( + () => session.UpdateOnAir(mismatchedCue, options.LayoutIndex)); + updateCalls = log.Names.Skip(updateStart).ToArray(); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(updateCalls); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + } + + [Fact] + public async Task UpdateOnAir_WithoutRetainedCurrentSceneFailsBeforeAnySdkMutation() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var cue = SceneCue(options, "5001"); + var log = new FakeComLog(); + var player = new FakePlayer(log) { PlayingScene = new object() }; + var scene = new FakeScene(log, "5001"); + var activator = new FakeActivator(log, new FakeEngine(log, player, scene)); + await using var dispatcher = new StaDispatcher(capacity: 2); + string[] updateCalls = []; + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + + var updateStart = log.Names.Length; + Assert.Throws( + () => session.UpdateOnAir(cue, options.LayoutIndex)); + updateCalls = log.Names.Skip(updateStart).ToArray(); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(updateCalls); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + } + + [Fact] + public async Task UpdateOnAir_WithAnotherPreparedSceneFailsBeforeAnySdkMutation() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var cue5001 = SceneCue(options, "5001"); + var log = new FakeComLog(); + var player = new FakePlayer(log) { PlayingScene = new object() }; + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene(log, "5006"); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + await using var dispatcher = new StaDispatcher(capacity: 2); + string[] updateCalls = []; + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + session.Prepare(cue5001, options.LayoutIndex); + session.Play(options.LayoutIndex); + session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); + + var updateStart = log.Names.Length; + Assert.Throws( + () => session.UpdateOnAir(cue5001, options.LayoutIndex)); + updateCalls = log.Names.Skip(updateStart).ToArray(); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(updateCalls); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + } + + [Fact] + public async Task UpdateOnAir_WithPendingInitialPlayFailsBeforeAnySdkMutation() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var options = SceneOptions(scenes.Path, "5001"); var cue = SceneCue(options, "5001"); var log = new FakeComLog(); var scene = new FakeScene(log, "5001"); - var player = new FakePlayer(log) { PlayingScene = scene }; + var player = new FakePlayer(log) { PlayingScene = new object() }; + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + string[] updateCalls = []; + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(cue, options.LayoutIndex); + session.Play(options.LayoutIndex); + Assert.True(session.HasPendingPlayCallbacks); + + var updateStart = log.Names.Length; + Assert.Throws( + () => session.UpdateOnAir(cue, options.LayoutIndex)); + updateCalls = log.Names.Skip(updateStart).ToArray(); + + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + session.ProcessPendingCallbacks(options.LayoutIndex); + Assert.False(session.HasPendingPlayCallbacks); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(updateCalls); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); + } + + [Fact] + public async Task UpdateOnAir_TransactionFailureRollsBackAndRetainsSceneForStopAllCleanup() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var initialCue = SceneCue(options, "5001"); + var updateCue = options.ResolveCue(new PlayoutCue( + "5001.t2s", + "5001", + [new PlayoutField("headline", "refresh", true)], + Mutations: [new PlayoutUseBackground(false)])); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log) { PlayingScene = new object() }; + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + session.Prepare(initialCue, options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + session.ProcessPendingCallbacks(options.LayoutIndex); + + scene.QueryVariablesFailure = new InvalidOperationException("fake refresh failure"); + var updateStart = log.Names.Length; + Assert.Throws( + () => session.UpdateOnAir(updateCue, options.LayoutIndex)); + var updateCalls = log.Names.Skip(updateStart).ToArray(); + Assert.Equal( + new[] + { + "UseBackground:0", + "BeginTransaction", + "GetObject:headline", + "SetValue:headline:refresh", + "SetVisible:headline:1", + "QueryVariables", + "RollbackTransaction" + }, + updateCalls); + Assert.DoesNotContain("Release:5001", log.Names); + Assert.DoesNotContain("Unload:5001", log.Names); + + scene.QueryVariablesFailure = null; + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 1); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + } + + [Fact] + public async Task UpdateOnAir_CallbackSession_TracksPlayAndRetainsSceneUntilStopAll() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var cue = SceneCue(options, "5001"); + var log = new FakeComLog(); + var scene = new FakeScene(log, "5001"); + var player = new FakePlayer(log) { PlayingScene = new object() }; var engine = new FakeEngine(log, player, scene); var activator = new FakeActivator(log, engine); var callbacks = new RecordingEventHandlerFactory(); @@ -577,6 +830,8 @@ public sealed class DynamicK3dSessionTests session.UpdateOnAir(cue, options.LayoutIndex); Assert.True(session.HasPendingLifecycleCallbacks); + Assert.DoesNotContain("Release:5001", log.Names); + Assert.DoesNotContain("Unload:5001", log.Names); callbacks.Fire("OnScenePlayed", 1, 8, 10); session.ProcessPendingCallbacks(options.LayoutIndex); @@ -585,6 +840,13 @@ public sealed class DynamicK3dSessionTests callbacks.Fire("OnScenePlayed", 1, 9, 10); session.ProcessPendingCallbacks(options.LayoutIndex); Assert.False(session.HasPendingLifecycleCallbacks); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 1); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.False(session.HasPendingLifecycleCallbacks); session.Disconnect(); return true; }, @@ -592,8 +854,9 @@ public sealed class DynamicK3dSessionTests CancellationToken.None); Assert.Equal(2, log.Names.Count(name => name == "Play:10")); - Assert.Equal(1, log.Names.Count(name => name == "GetPlayingScene:10")); - Assert.DoesNotContain("Unload:5001", log.Names); + Assert.DoesNotContain("GetPlayingScene:10", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); } [Fact] @@ -1729,7 +1992,7 @@ public sealed class DynamicK3dSessionTests { private readonly FakeComLog _log; private readonly string? _name; - private readonly Exception? _queryVariablesFailure; + private Exception? _queryVariablesFailure; private readonly Exception? _unloadFailure; public FakeScene( @@ -1773,6 +2036,12 @@ public sealed class DynamicK3dSessionTests } } + public Exception? QueryVariablesFailure + { + get => _queryVariablesFailure; + set => _queryVariablesFailure = value; + } + public void Unload() { _log.Add(_name is null ? "Unload" : $"Unload:{_name}");