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
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using Microsoft.Extensions.Logging;
using Rhino;
using Rhino.Runtime.InProcess;
Expand All @@ -9,55 +9,34 @@

namespace Speckle.Importers.Rhino.Internal;

internal sealed class ImporterInstance(Sender sender, ILogger<ImporterInstance> logger) : IDisposable
internal sealed class ImporterInstance(ImporterArgs args, Sender sender, ILogger<ImporterInstance> logger) : IDisposable
{
private readonly ILogger _logger = logger;
private readonly RhinoCore _rhinoInstance = new(["/netcore-8"], WindowStyle.NoWindow);

private RhinoDoc? _rhinoDoc;
private readonly RhinoDoc _rhinoDoc = OpenDocument(args, logger);

public async Task<ImporterResponse> Run(ImporterArgs args, CancellationToken cancellationToken)
{
using var scopeJobId = ActivityScope.SetTag("jobId", args.JobId);
// using var scopeJobType = ActivityScope.SetTag("jobType", a.JobType);
using var scopeAttempt = ActivityScope.SetTag("job.attempt", args.Attempt.ToString());
using var scopeServerUrl = ActivityScope.SetTag("serverUrl", args.Account.serverInfo.url);
using var scopeProjectId = ActivityScope.SetTag("projectId", args.Project.id);
using var scopeModelId = ActivityScope.SetTag("modelId", args.ModelId);
using var scopeBlobId = ActivityScope.SetTag("blobId", args.BlobId);
using var scopeFileType = ActivityScope.SetTag("fileType", Path.GetExtension(args.FilePath).TrimStart('.'));
UserActivityScope.AddUserScope(args.Account);

var result = await TryImport(args, cancellationToken);
return result;
}
private readonly IReadOnlyList<IDisposable> _scopes =
[
ActivityScope.SetTag("jobId", args.JobId),
ActivityScope.SetTag("job.attempt", args.Attempt.ToString()),
// ActivityScope.SetTag("jobType", args.JobType),
ActivityScope.SetTag("serverUrl", args.Account.serverInfo.url),
ActivityScope.SetTag("projectId", args.Project.id),
ActivityScope.SetTag("modelId", args.ModelId),
ActivityScope.SetTag("blobId", args.BlobId),
ActivityScope.SetTag("fileType", Path.GetExtension(args.FilePath).TrimStart('.')),
UserActivityScope.AddUserScope(args.Account),
];

[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "IPC")]
private async Task<ImporterResponse> TryImport(ImporterArgs args, CancellationToken cancellationToken)
public async Task<Version> RunRhinoImport(CancellationToken cancellationToken)
{
try
{
var version = await RunRhinoImport(args, cancellationToken);
return new ImporterResponse { Version = version, ErrorMessage = null };
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Import attempt failed with exception");
return new ImporterResponse { ErrorMessage = ex.Message, Version = null };
}
}

private async Task<Version> RunRhinoImport(ImporterArgs args, CancellationToken cancellationToken)
{
try
{
using var config = GetConfig(Path.GetExtension(args.FilePath));
logger.LogInformation("Opening file {FilePath}", args.FilePath);

_rhinoDoc = config.OpenInHeadlessDocument(args.FilePath);
RhinoDoc.ActiveDoc = _rhinoDoc;

var version = await sender.Send(args.Project, args.ModelId, args.Account, cancellationToken);
var version = await sender
.Send(args.Project, args.ModelId, args.Account, cancellationToken)
.ConfigureAwait(false);
return version;
}
finally
Expand All @@ -66,6 +45,14 @@ private async Task<Version> RunRhinoImport(ImporterArgs args, CancellationToken
}
}

private static RhinoDoc OpenDocument(ImporterArgs args, ILogger logger)
{
using var config = GetConfig(Path.GetExtension(args.FilePath));
logger.LogInformation("Opening file {FilePath}", args.FilePath);
return config.OpenInHeadlessDocument(args.FilePath);
}

[Pure]
private static IFileTypeConfig GetConfig(string extension) =>
extension.ToLowerInvariant() switch
{
Expand All @@ -83,5 +70,9 @@ public void Dispose()
// https://discourse.mcneel.com/t/rhino-inside-fatal-app-crashes-when-disposing-headless-documents/208673
_rhinoDoc?.Dispose();
_rhinoInstance.Dispose();
foreach (var scope in _scopes)
{
scope.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;

namespace Speckle.Importers.Rhino.Internal;

internal sealed class ImporterInstanceFactory(Sender sender, ILogger<ImporterInstance> logger)
{
public ImporterInstance Create(ImporterArgs args) => new(args, sender, logger);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static IServiceCollection AddRhinoImporter(this IServiceCollection servic
services.AddTransient<Progress>();
services.AddTransient<Sender>();
services.AddTransient<ImporterInstance>();
services.AddTransient<ImporterInstanceFactory>();

// override default thread context
services.AddSingleton<IThreadContext>(new ImporterThreadContext());
Expand Down
47 changes: 42 additions & 5 deletions Importers/Rhino/Speckle.Importers.Rhino/Program.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Text.Json;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RhinoInside;
using Speckle.Importers.Rhino.Internal;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;

namespace Speckle.Importers.Rhino;

Expand All @@ -18,9 +20,12 @@ static Program()
Console.WriteLine($"Loading Rhino @ {Resolver.RhinoSystemDirectory}");
}

[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "IPC")]
public static async Task Main(string[] args)
{
ILogger? logger = null;
ImporterInstance? importer = null;

try
{
var importerArgs = JsonSerializer.Deserialize<ImporterArgs>(args[0], s_serializerOptions);
Expand All @@ -32,13 +37,34 @@ public static async Task Main(string[] args)
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
logger.LogCritical(eventArgs.Exception, "Unobserved Task Exception");

var importer = serviceProvider.GetRequiredService<ImporterInstance>();
var factory = serviceProvider.GetRequiredService<ImporterInstanceFactory>();

// Error handling flow below here looks a bit of a mess, but we're having to navigate threading issues with rhino inside
// https://discourse.mcneel.com/t/rhino-inside-fatal-app-crashes-when-disposing-headless-documents/208673/7
try
{
// This needs to be called on the main thread
importer = factory.Create(importerArgs);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main change. We're opening the document here on construction of the importer.
This way, we're doing it on the main thread.

Previously, this was done from a .NET worker thread inside the Task.Run block lower down.

Copy link
Member

@oguzhankoral oguzhankoral Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we ever need to run things on a worker thread in an importer environment?

re your PR desc, even if McNeel fixes, is not the safe to run always on main?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. There's another issue with Rhino inside where, as soon as the main thread is yielded (via the await keyword), It will be hogged for ever by something internal to Rhino.

This means, once an await keyword is used, anywhere, then we've lost control over the main thread now for the remaining process life span.

This means we have to be careful to use the main thread first, and then use async after.
That's what this PR achieves. but it's working around several weird behaviours of Rhino Inside.

I get the impression that Rhino Inside wasn't really built to be very compatible with await async, since none of the examples from mcneel involve async.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense to me, got it thanks!

I hope i am not annoying but what stop us to run everything sync?

}
catch (Exception ex)
{
WriteResult(new() { ErrorMessage = ex.Message }, importerArgs.ResultsPath);
return;
}

// As soon as the main thread is yielded, it will be hogged by Rhino
// Task.Run ensures we run everything on a thread pool thread.
await Task.Run(async () =>
{
var result = await importer.Run(importerArgs, CancellationToken.None);
var serializedResult = JsonSerializer.Serialize(result, s_serializerOptions);
File.WriteAllLines(importerArgs.ResultsPath, [serializedResult]);
try
{
Version result = await importer.RunRhinoImport(CancellationToken.None).ConfigureAwait(false);
WriteResult(new() { Version = result }, importerArgs.ResultsPath);
}
catch (Exception ex)
{
WriteResult(new() { ErrorMessage = ex.Message }, importerArgs.ResultsPath);
}
})
.ConfigureAwait(false);
}
Expand All @@ -53,7 +79,18 @@ await Task.Run(async () =>
{
Console.WriteLine(MESSAGE);
}

throw;
}
finally
{
importer?.Dispose();
}
}

private static void WriteResult(ImporterResponse result, string resultsPath)
{
var serializedResult = JsonSerializer.Serialize(result, s_serializerOptions);
File.WriteAllLines(resultsPath, [serializedResult]);
}
}