From b35cb37e4e5065b8364e1cf7cccf4456fec4d15f Mon Sep 17 00:00:00 2001 From: WhiteBlackGoose Date: Sat, 9 Jul 2022 08:53:12 +0300 Subject: [PATCH 1/2] #320, #306 --- src/Plotly.NET.ImageExport/AsyncHelper.fs | 77 +++++++++++++++++++ src/Plotly.NET.ImageExport/ChartExtensions.fs | 12 +-- .../IGenericChartRenderer.fs | 13 ++-- .../Plotly.NET.ImageExport.fsproj | 1 + .../PuppeteerSharpRenderer.fs | 48 +++++------- .../ImageExport.fs | 8 +- 6 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 src/Plotly.NET.ImageExport/AsyncHelper.fs diff --git a/src/Plotly.NET.ImageExport/AsyncHelper.fs b/src/Plotly.NET.ImageExport/AsyncHelper.fs new file mode 100644 index 000000000..a68d57c16 --- /dev/null +++ b/src/Plotly.NET.ImageExport/AsyncHelper.fs @@ -0,0 +1,77 @@ +module Plotly.NET.ImageExport.AsyncHelper + +open System.Threading +open System.Threading.Tasks + +(* + +This is a workaround to avoid deadlocks + +https://medium.com/rubrikkgroup/understanding-async-avoiding-deadlocks-e41f8f2c6f5d + +TL;DR in many cases, for example, GUI apps, SynchronizationContext +is overriden to *post* the executing code on the initial (UI) thread. For example, +consider this code + +public async Task OnClick1() +{ + var chart = ...; + var base64 = ImageExport.toBase64PNGStringAsync()(chart).Result; + myButton.Text = base64; +} + +Here we have an async method. Normally you should use await and not use .Result, but +assume for some reason the sync version is used. What happens under the hood is, + +public async Task OnClick1() +{ + var chart = ...; + var task = ImageExport.toBase64PNGStringAsync()(chart); + task.ContinueWith(() => + UIThread.Schedule(() => + myButton.Text = Result; + ) + ); + task.Wait(); +} + +(this is pseudo-code) + +So basically, we set the task to wait until it finishes. However, part of it being +finished is to actually execute the code with button.Text = .... The waiting happens +on the UI thread, exactly on the same thread as where we're waiting for it to do +another job! + +That's not the only place we potentially deadlock by using fake synchronous functions. +The reason why it happens, is because frameworks (or actually anyone) override +SynchronizationContext. In GUI and game development it's very useful to keep UI logic +on one thread. But our rendering does not ever callback to it, we're independent of +where the logic actually happens. + +That's why what we do is we set the synchronization context to null, do the job, and +then restore it. It is a workaround, because it doesn't have to work everywhere and +independently. But it will work for most cases. + +When will it also break? For example, if we decide to take in some callback as a para- +meter, which potentially accesses the UI thread (or whatever). In Unity, for instance, +you can only access Unity API from the main thread. So our fake synchronous function +will crash in the end, because due to the overriden (by us) sync context, the callback +will be executed in some random thread (as opposed to being posted back to the UI one). + +However, our solution should work in most cases. + +Credit to [@DaZombieKiller](https://github.com/DaZombieKiller) for helping. + +*) + +let runSync job input = + let current = SynchronizationContext.Current + SynchronizationContext.SetSynchronizationContext null + try + job input + finally + SynchronizationContext.SetSynchronizationContext current + +let taskSync (task : Task<'a>) = task |> runSync (fun t -> t.Result) + +let taskSyncUnit (task : Task) = task |> runSync (fun t -> t.Wait()) diff --git a/src/Plotly.NET.ImageExport/ChartExtensions.fs b/src/Plotly.NET.ImageExport/ChartExtensions.fs index 7e969a69b..39f229c5e 100644 --- a/src/Plotly.NET.ImageExport/ChartExtensions.fs +++ b/src/Plotly.NET.ImageExport/ChartExtensions.fs @@ -56,7 +56,7 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.toBase64JPGStringAsync (?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync /// /// Returns an async function that saves a GenericChart as JPG image @@ -97,7 +97,7 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.saveJPGAsync (path, ?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync /// /// Returns an async function that converts a GenericChart to a base64 encoded PNG string @@ -134,7 +134,7 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.toBase64PNGStringAsync (?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync /// /// Returns an async function that saves a GenericChart as PNG image @@ -175,7 +175,7 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.savePNGAsync (path, ?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync /// /// Returns an async function that converts a GenericChart to a SVG string @@ -211,7 +211,7 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.toSVGStringAsync (?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync /// /// Returns an async function that saves a GenericChart as SVG image @@ -251,4 +251,4 @@ module ChartExtensions = fun (gChart: GenericChart) -> gChart |> Chart.saveSVGAsync (path, ?EngineType = EngineType, ?Width = Width, ?Height = Height) - |> Async.RunSynchronously + |> AsyncHelper.taskSync diff --git a/src/Plotly.NET.ImageExport/IGenericChartRenderer.fs b/src/Plotly.NET.ImageExport/IGenericChartRenderer.fs index 37992c066..3efd2c37c 100644 --- a/src/Plotly.NET.ImageExport/IGenericChartRenderer.fs +++ b/src/Plotly.NET.ImageExport/IGenericChartRenderer.fs @@ -1,5 +1,6 @@ namespace Plotly.NET.ImageExport +open System.Threading.Tasks open Plotly.NET /// @@ -8,31 +9,31 @@ open Plotly.NET type IGenericChartRenderer = ///Async function that returns a base64 encoded string representing the input chart as JPG file with the given width and height - abstract member RenderJPGAsync: int * int * GenericChart.GenericChart -> Async + abstract member RenderJPGAsync: int * int * GenericChart.GenericChart -> Task ///Function that returns a base64 encoded string representing the input chart as JPG file with the given width and height abstract member RenderJPG: int * int * GenericChart.GenericChart -> string ///Async function that saves the input chart as JPG file with the given width and height at the given path - abstract member SaveJPGAsync: string * int * int * GenericChart.GenericChart -> Async + abstract member SaveJPGAsync: string * int * int * GenericChart.GenericChart -> Task ///Function that saves the input chart as JPG file with the given width and height at the given path abstract member SaveJPG: string * int * int * GenericChart.GenericChart -> unit ///Async function that returns a base64 encoded string representing the input chart as PNG file with the given width and height - abstract member RenderPNGAsync: int * int * GenericChart.GenericChart -> Async + abstract member RenderPNGAsync: int * int * GenericChart.GenericChart -> Task ///Function that returns a base64 encoded string representing the input chart as PNG file with the given width and height abstract member RenderPNG: int * int * GenericChart.GenericChart -> string ///Async function that saves the input chart as PNG file with the given width and height at the given path - abstract member SavePNGAsync: string * int * int * GenericChart.GenericChart -> Async + abstract member SavePNGAsync: string * int * int * GenericChart.GenericChart -> Task ///Function that saves the input chart as PNG file with the given width and height at the given path abstract member SavePNG: string * int * int * GenericChart.GenericChart -> unit ///Async function that returns a string representing the input chart as SVG file with the given width and height - abstract member RenderSVGAsync: int * int * GenericChart.GenericChart -> Async + abstract member RenderSVGAsync: int * int * GenericChart.GenericChart -> Task ///Function that returns string representing the input chart as SVG file with the given width and height abstract member RenderSVG: int * int * GenericChart.GenericChart -> string ///Async function that saves the input chart as SVG file with the given width and height at the given path - abstract member SaveSVGAsync: string * int * int * GenericChart.GenericChart -> Async + abstract member SaveSVGAsync: string * int * int * GenericChart.GenericChart -> Task ///Function that saves the input chart as SVG file with the given width and height at the given path abstract member SaveSVG: string * int * int * GenericChart.GenericChart -> unit diff --git a/src/Plotly.NET.ImageExport/Plotly.NET.ImageExport.fsproj b/src/Plotly.NET.ImageExport/Plotly.NET.ImageExport.fsproj index 4181bf782..54236ac10 100644 --- a/src/Plotly.NET.ImageExport/Plotly.NET.ImageExport.fsproj +++ b/src/Plotly.NET.ImageExport/Plotly.NET.ImageExport.fsproj @@ -25,6 +25,7 @@ + diff --git a/src/Plotly.NET.ImageExport/PuppeteerSharpRenderer.fs b/src/Plotly.NET.ImageExport/PuppeteerSharpRenderer.fs index a8a3f1d0c..2e5b7a848 100644 --- a/src/Plotly.NET.ImageExport/PuppeteerSharpRenderer.fs +++ b/src/Plotly.NET.ImageExport/PuppeteerSharpRenderer.fs @@ -1,5 +1,7 @@ namespace Plotly.NET.ImageExport +open System.Threading +open System.Threading.Tasks open Plotly.NET open PuppeteerSharp @@ -20,7 +22,7 @@ module PuppeteerSharpRendererOptions = type PuppeteerSharpRenderer() = - + /// adapted from the original C# implementation by @ilyalatt : https://github.com/ilyalatt/Plotly.NET.PuppeteerRenderer /// /// creates a full screen html site for the given chart @@ -61,7 +63,7 @@ type PuppeteerSharpRenderer() = /// /// attempts to render a chart as static image of the given format with the given dimensions from the given html string let tryRenderAsync (browser: Browser) (width: int) (height: int) (format: StyleParam.ImageFormat) (html: string) = - async { + task { let! page = browser.NewPageAsync() |> Async.AwaitTask try @@ -71,41 +73,33 @@ type PuppeteerSharpRenderer() = return imgStr finally - page.CloseAsync() |> Async.AwaitTask |> Async.RunSynchronously + page.CloseAsync() |> AsyncHelper.taskSyncUnit } - /// attempts to render a chart as static image of the given format with the given dimensions from the given html string - let tryRender (browser: Browser) (width: int) (height: int) (format: StyleParam.ImageFormat) (html: string) = - tryRenderAsync browser width height format html |> Async.RunSynchronously - /// Initalizes headless browser let fetchAndLaunchBrowserAsync () = - async { + task { match PuppeteerSharpRendererOptions.localBrowserExecutablePath with | None -> use browserFetcher = new BrowserFetcher() - let! revision = browserFetcher.DownloadAsync() |> Async.AwaitTask + let! revision = browserFetcher.DownloadAsync() let launchOptions = PuppeteerSharpRendererOptions.launchOptions launchOptions.ExecutablePath <- revision.ExecutablePath - return! Puppeteer.LaunchAsync(launchOptions) |> Async.AwaitTask + return! Puppeteer.LaunchAsync(launchOptions) | Some p -> let launchOptions = PuppeteerSharpRendererOptions.launchOptions launchOptions.ExecutablePath <- p - return! Puppeteer.LaunchAsync(launchOptions) |> Async.AwaitTask + return! Puppeteer.LaunchAsync(launchOptions) } - /// Initalizes headless browser - let fetchAndLaunchBrowser () = - fetchAndLaunchBrowserAsync () |> Async.RunSynchronously - /// skips the data type part of the given URI let skipDataTypeString (base64: string) = let imgBase64StartIdx = @@ -120,7 +114,7 @@ type PuppeteerSharpRenderer() = interface IGenericChartRenderer with member this.RenderJPGAsync(width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { use! browser = fetchAndLaunchBrowserAsync () return! tryRenderAsync browser width height StyleParam.ImageFormat.JPEG (gChart |> toFullScreenHtml) @@ -129,10 +123,10 @@ type PuppeteerSharpRenderer() = member this.RenderJPG(width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .RenderJPGAsync(width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync member this.SaveJPGAsync(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { let! rendered = (this :> IGenericChartRenderer) .RenderJPGAsync(width, height, gChart) @@ -143,10 +137,10 @@ type PuppeteerSharpRenderer() = member this.SaveJPG(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .SaveJPGAsync(path, width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync member this.RenderPNGAsync(width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { use! browser = fetchAndLaunchBrowserAsync () return! tryRenderAsync browser width height StyleParam.ImageFormat.PNG (gChart |> toFullScreenHtml) @@ -155,10 +149,10 @@ type PuppeteerSharpRenderer() = member this.RenderPNG(width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .RenderPNGAsync(width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync member this.SavePNGAsync(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { let! rendered = (this :> IGenericChartRenderer) .RenderPNGAsync(width, height, gChart) @@ -169,10 +163,10 @@ type PuppeteerSharpRenderer() = member this.SavePNG(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .SavePNGAsync(path, width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync member this.RenderSVGAsync(width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { use! browser = fetchAndLaunchBrowserAsync () let! renderedString = @@ -184,10 +178,10 @@ type PuppeteerSharpRenderer() = member this.RenderSVG(width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .RenderSVGAsync(width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync member this.SaveSVGAsync(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = - async { + task { let! rendered = (this :> IGenericChartRenderer) .RenderSVGAsync(width, height, gChart) @@ -198,4 +192,4 @@ type PuppeteerSharpRenderer() = member this.SaveSVG(path: string, width: int, height: int, gChart: GenericChart.GenericChart) = (this :> IGenericChartRenderer) .SaveSVGAsync(path, width, height, gChart) - |> Async.RunSynchronously + |> AsyncHelper.taskSync diff --git a/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs b/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs index 68c1d97b6..f223c9474 100644 --- a/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs +++ b/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs @@ -29,7 +29,7 @@ let ``Image export tests`` = ptestAsync "Chart.toBase64JPGStringAsync" { let testBase64JPG = readTestFilePlatformSpecific "TestBase64JPG.txt" - let! actual = (Chart.Point([1.,1.]) |> Chart.toBase64JPGStringAsync()) + let! actual = (Chart.Point([1.,1.]) |> Chart.toBase64JPGStringAsync() |> Async.AwaitTask) return Expect.equal @@ -40,7 +40,7 @@ let ``Image export tests`` = ptestAsync "Chart.toBase64PNGStringAsync" { let testBase64PNG = readTestFilePlatformSpecific "TestBase64PNG.txt" - let! actual = (Chart.Point([1.,1.]) |> Chart.toBase64PNGStringAsync()) + let! actual = (Chart.Point([1.,1.]) |> Chart.toBase64PNGStringAsync() |> Async.AwaitTask) return Expect.equal @@ -48,5 +48,9 @@ let ``Image export tests`` = testBase64PNG "Invalid base64 string for Chart.toBase64PNGStringAsync" } + testCase "Chart.toBase64JPGString" <| fun () -> + let actual = Chart.Point([1.,1.]) |> Chart.toBase64JPGString() + Expect.isTrue (actual.Length > 100) "" + ] ) From bbeacb7a2665fb9232e641901500790e1bb5c603 Mon Sep 17 00:00:00 2001 From: WhiteBlackGoose Date: Mon, 11 Jul 2022 09:52:58 +0300 Subject: [PATCH 2/2] Tests added --- tests/Plotly.NET.ImageExport.Tests/ImageExport.fs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs b/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs index f223c9474..1b37bf083 100644 --- a/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs +++ b/tests/Plotly.NET.ImageExport.Tests/ImageExport.fs @@ -48,9 +48,14 @@ let ``Image export tests`` = testBase64PNG "Invalid base64 string for Chart.toBase64PNGStringAsync" } - testCase "Chart.toBase64JPGString" <| fun () -> + testCase "Chart.toBase64JPGString terminates" <| fun () -> let actual = Chart.Point([1.,1.]) |> Chart.toBase64JPGString() Expect.isTrue (actual.Length > 100) "" - + testCase "Chart.toBase64PNGString terminates" <| fun () -> + let actual = Chart.Point([1.,1.]) |> Chart.toBase64PNGString() + Expect.isTrue (actual.Length > 100) "" + testCase "Chart.toSVGString terminates" <| fun () -> + let actual = Chart.Point([1.,1.]) |> Chart.toSVGString() + Expect.isTrue (actual.Length > 100) "" ] )