C# making app support plugins, run multiple instances, possible? - c#

Basically what I need is being able to add new functionality to my application, without updating the application itself.
Lets say I have an application with two plugins.
Application has a function called generateNumber();
Plugin_1 would have the following code:
void init(){ }
int exec(){
int number = HOST_APPLICATION.generateNumber();
number = number * 2;
return number;
}
Plugin_2 would have the same structure, only different functionality:
void init(){ }
int exec(){
int number = HOST_APPLICATION.generateNumber();
number = (number * 10) + 13;
return number;
}
I need multiple instances of each 'plugin' to run simultaneously (each in its own thread), so 20 threads total at the same time. The code would be something like this:
Main Application:
void init_plugins() { }
void execute_plugins(){
for(int i=0; i<plugins.count; i++){
for(int z=0; z<10; z++){
Thread t = new Thread(plugin_research);
t.start(plugins[i]);
}
}
}
void plugin_research(PluginStructure Plugin){
Plugin.init();
int return_val = Plugin.exec();
// do something with return_val
}
I also need host application to call plugin's functions (the structure will be the same for all plugins) and plugin to be able to call host application's functions.
Each plugin would have different controls for configuration. I would need to show configuration in one place, but call plugin's functions multiple times simultaneously (using threads)
Is this possible? What would be the best way to accomplish this? can I do this with iPlugin?

Look into MEF, the managed extensibility framework at http://mef.codeplex.com/. It has a lot of built in support for runtime discovery of plugin components.

Basicly each plugin should be derived from a base class exposing methods you want.
public abstract class BasePlugin
{
protected int number;
public abstract void init_plugin();
public abstract int exec(int number);
public BasePlugin(int number)
{
this.number = number;
}
}
Then you have
public class Plugin1: BasePlugin
{
public override void init_plugin()
{
}
public override int exec(int number)
{
}
}
In your app you can create plugins and keep them in a list
List<BasePlugin> list = new List<BasePlugin>();
list.Add(new Plugin1(generateNumber()));
BasePlugin p2 = new Plugin2(generateNumber());
p2.init_plugin();
list.Add(p2);
and do with loaded plugins whatever you please.
Or (as I see from your edited question) you can create threads for every plugin...
To load plugins you could use functions like these:
public static List<T> GetFilePlugins<T>(string filename)
{
List<T> ret = new List<T>();
if (File.Exists(filename))
{
Type typeT = typeof(T);
Assembly ass = Assembly.LoadFrom(filename);
foreach (Type type in ass.GetTypes())
{
if (!type.IsClass || type.IsNotPublic) continue;
if (typeT.IsAssignableFrom(type))
{
T plugin = (T)Activator.CreateInstance(type);
ret.Add(plugin);
}
}
}
return ret;
}
public static List<T> GetDirectoryPlugins<T>(string dirname)
{
List<T> ret = new List<T>();
string[] dlls = Directory.GetFiles(dirname, "*.dll");
foreach (string dll in dlls)
{
List<T> dll_plugins = GetFilePlugins<T>(Path.GetFullPath(dll));
ret.AddRange(dll_plugins);
}
return ret;
}

Check out MEF - http://msdn.microsoft.com/en-us/library/dd460648.aspx. Even if you decide not to use it you can see how such architecture can be implemented and used.

Look into MEF, the Managed Extensibility Framework.

Related

Multiple instances of wrapper for unmanaged code referencing same dll

I'm using a managed C# wrapper to access an unmanaged C++ library. The library does some time consumung caluclations (up to 6 seconds) that I need. But also in parallel to that I continuously need some data that is fast to get too.
To achieve that, I tried to get two instances of my wrapper, one for the quick stuff and the other one in a parallel thread to calculate the time consuming information. But, as soon as I instanciate the slow one of the Analyzers, even the quick one gets slow too.
fullAnalyzer = new Analyzer(FullAnalysis);
miniAnalyzer = new Analyzer(MinimalAnalysis);
It looks like both of them are sharing the same configuration in the back, because if I instanciate the quick one first, it is still fast.
Is it in general possible to have two or more seperate instances of a wrapper accessing an unmanaged library without interfering? I so - how is it done? Or is this behaviour just a local thing of this library?
Edit: This is the constructor and some part of the wrapper code
public class ScrWrapper
{
private const string DllName = #"Analyzer.dll";
public bool IsConfigLoaded { get; private set; }
public bool IsAnalyticsSuccessful { get; private set; }
public Analyzer()
{
IsConfigLoaded = false;
IsAnalyticsSuccessful = false;
}
public Analyzer(string configFileName, ScrProcLevel procLevel = ScrProcLevel.PL_NONE)
{
IsConfigLoaded = false;
IsAnalyticsSuccessful = false;
LoadConfig(configFileName, procLevel);
}
public void LoadConfig(string configFileName, ProcLevel procLevel = ScrProcLevel.PL_NONE)
{
if (configFileName.Length < 1)
throw new ArgumentException("Empty configFileName. Must contain valid file name.");
if (!System.IO.File.Exists(configFileName))
throw new ArgumentException(String.Format("Invalid configFileName. File not found: {0}",configFileName));
if (!System.IO.File.Exists(DllName))
throw new ArgumentException(String.Format("Invalid DllName. File not found: {0}", DllName));
bool b_config_status = false;
try
{
StringBuilder sb = new StringBuilder(configFileName);
ScanAuto_EnableWriteOut(true);
b_config_status = ScanAuto_LoadConfig(sb);
}
catch (Exception ex)
{
throw new ScrException("ERROR: Unmanaged Analyzer threw exception.", ex);
}
if (!b_config_status)
{
throw new ScrException(String.Format("ERROR: Failed to load the configurationfile, b_config_status=false"));
}
IsConfigLoaded = b_config_status;
_ProcLevel = procLevel;
Analyzer_SetProcLevel(_ProcLevel);
}
...
[DllImport(DllName, CallingConvention = CallConvention)]
[return: MarshalAs(UnmanagedType.I1)]
private extern static bool ScanAuto_LoadConfig(StringBuilder _pConfigFName);
}
Your wrapper looks fine from what I can tell, so its the fact that ScanAuto_LoadConfig method (and therefore the members it's initialising) is static, causing it to overwrite the same bit of config each time. see Static Data Members (C++)

Are MEF exports cached or discovering every time on request?

If I have one type MyClass, register with
[Export(typeof(Myclass))]
attribute, and
[PartCreationPolicy(CreationPolicy.Shared)]
or
[PartCreationPolicy(CreationPolicy.NonShared)]
and later trying to call
compositionContainer.GetExportedValue<Myclass>() multiple times.
Question: with the first call, I will get my registered class via MEF - llokup all registered assemblies, then trying to find one registered contract. Question is about second time and so on - will MEF do global lookup again or it caches somewhere internally?
will MEF do global lookup again or it caches somewhere internally
Yes, MEF perfoms some caching and widely uses lazy initialization, if you question is about MEF performance:
1) metadata (composable parts, export definitions and import definitions) is cached. Example:
public override IEnumerable<ExportDefinition> ExportDefinitions
{
get
{
if (this._exports == null)
{
ExportDefinition[] exports = this._creationInfo.GetExports().ToArray<ExportDefinition>();
lock (this._lock)
{
if (this._exports == null)
{
this._exports = exports;
}
}
}
return this._exports;
}
}
2) exported values are cached too:
public object Value
{
get
{
if (this._exportedValue == Export._EmptyValue)
{
object exportedValueCore = this.GetExportedValueCore();
Interlocked.CompareExchange(ref this._exportedValue, exportedValueCore, Export._EmptyValue);
}
return this._exportedValue;
}
}
Of course, when using CreationPolicy.NonShared, exported value becomes created again and again, when you requesting it. But even in this case "global lookup" isn't performed, because metadata is cached anyway.
It does a lookup every time, when you use [PartCreationPolicy(CreationPolicy.NonShared)]. You then have to implement the caching yourself.
The default implementation is using a Singleton pattern. This equals the attribute [PartCreationPolicy(CreationPolicy.Shared)]. This is the best practice.
For more information, read http://blogs.microsoft.co.il/blogs/bnaya/archive/2010/01/09/mef-for-beginner-part-creation-policy-part-6.aspx
Although the values/metadata might be partially cached, doing some performance testing shows that some lookup is performed every time a call to GetExportedValue is made. So if you have many calls where you need to get the value, you should do the caching yourself.
namespace MEFCachingTest
{
using System;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.ComponentModel.Composition.Primitives;
using System.Diagnostics;
using System.Reflection;
public static class Program
{
public static CompositionContainer Container { get; set; }
public static ComposablePartCatalog Catalog { get; set; }
public static ExportedClass NonCachedClass
{
get
{
return Container.GetExportedValue<ExportedClass>();
}
}
private static ExportedClass cachedClass;
public static ExportedClass CachedClass
{
get
{
return cachedClass ?? (cachedClass = Container.GetExportedValue<ExportedClass>());
}
}
public static void Main()
{
Catalog = new AssemblyCatalog(Assembly.GetExecutingAssembly());
Container = new CompositionContainer(Catalog);
const int Runs = 1000000;
var stopwatch = new Stopwatch();
// Non-Cached.
stopwatch.Start();
for (int i = 0; i < Runs; i++)
{
var ncc = NonCachedClass;
}
stopwatch.Stop();
Console.WriteLine("Non-Cached: Time: {0}", stopwatch.Elapsed);
// Cached.
stopwatch.Restart();
for (int i = 0; i < Runs; i++)
{
var cc = CachedClass;
}
stopwatch.Stop();
Console.WriteLine(" Cached: Time: {0}", stopwatch.Elapsed);
}
}
[Export]
[PartCreationPolicy(CreationPolicy.Shared)]
public class ExportedClass
{
}
}
For more variations, look at this gist: https://gist.github.com/DanielRose/d79f0da2ef61591176ce
On my computer, Windows 7 x64, .NET 4.5.2:
Non-Cached: Time: 00:00:02.1217811
Cached: Time: 00:00:00.0063479
Using MEF 2 from NuGet:
Non-Cached: Time: 00:00:00.2037812
Cached: Time: 00:00:00.0023358
In the actual application where I work, this made the application 6x slower.

Modules Security in C# Windows Form Applications?

How can i separate my applications to different modules? End users will use only modules that they bought. Can i use that using plug-and-pluy?
You could to take a look into Managed Extensibility Framework to add modules dynamically.
Yes.
You can dynamically load assemblies based on a common interface or markup attributes.
Dynamic assembly loading
You might want to investigate Composite Application Block (CAB) (but it does have a learning curve)
MS has an example and tools for this in their Shareware Starter Kit für C#. Download from here:
http://msdn.microsoft.com/en-us/library/cc533530.aspx
It's somewhat old. But if you are looking for free resources (there are some commercial solutions) this is all that afaik exists without developing the entire infrastructure from scratch.
If you don't want to use MEF, try this.
First of all define (in a library) your interfaces:
namespace Interfaces
{
public interface IPlugin01
{
string Name { get; }
string Description { get; }
void Calc1();
}
public interface IPlugin02
{
void Calc2();
}
}
Then write your plugins (probably assemblies in DLL files) using classes implementing your interfaces (one or more):
namespace Plugin01
{
public class Class1 : Interfaces.IPlugin01,Interfaces.IPlugin02
{
public string Name { get { return "Plugin01.Class1"; } }
public string Description { get { return "Plugin01.Class1 description"; } }
public void Calc1() { Console.WriteLine("sono Plugin01.Class1 Calc1()"); }
public void Calc2() { Console.WriteLine("sono Plugin01.Class1 Calc2()"); }
}
public class Class2 : Interfaces.IPlugin01
{
public string Name { get { return "Plugin01.Class2"; } }
public string Description { get { return "Plugin01.Class2 description"; } }
public void Calc1() { Console.WriteLine("sono Plugin01.Class2 Calc1()"); }
}
}
Finally create your app that loads and uses your plugins:
namespace Test
{
class Program
{
/// ------------------------------------------------------------------------------
/// IMPORTANT:
/// you MUST exclude Interfaces.dll from being copied in Plugins directory,
/// otherwise plugins will use that and they're not recognized as using
/// the same IPlugin interface used in main code.
/// ------------------------------------------------------------------------------
static void Main(string[] args)
{
List<Interfaces.IPlugin01> list1 = new List<Interfaces.IPlugin01>();
List<Interfaces.IPlugin01> list2 = new List<Interfaces.IPlugin01>();
List<Interfaces.IPlugin01> listtot = GetDirectoryPlugins<Interfaces.IPlugin01>(#".\Plugins\");
Console.WriteLine("--- 001 ---");
foreach(Interfaces.IPlugin01 plugin in list1)
plugin.Calc1();
Console.WriteLine("--- 002 ---");
foreach (Interfaces.IPlugin01 plugin in list2)
plugin.Calc1();
Console.WriteLine("--- TOT ---");
foreach (Interfaces.IPlugin01 plugin in listtot)
plugin.Calc1();
Console.ReadLine();
}
public static List<T> GetFilePlugins<T>(string filename)
{
List<T> ret = new List<T>();
if (File.Exists(filename))
{
Assembly ass = Assembly.LoadFrom(filename);
foreach (Type type in ass.GetTypes())
{
if (!type.IsClass || type.IsNotPublic) continue;
if (typeof(T).IsAssignableFrom(type))
{
T plugin = (T)Activator.CreateInstance(type);
ret.Add(plugin);
}
}
}
return ret;
}
public static List<T> GetDirectoryPlugins<T>(string dirname)
{
/// To avoid that plugins use Interfaces.dll in their directory,
/// I delete the file before searching for plugins.
/// Not elegant perhaps, but functional.
string idll = Path.Combine(dirname, "Interfaces.dll");
if (File.Exists(idll)) File.Delete(idll);
List<T> ret = new List<T>();
string[] dlls = Directory.GetFiles(dirname, "*.dll");
foreach (string dll in dlls)
{
List<T> dll_plugins = GetFilePlugins<T>(Path.GetFullPath(dll));
ret.AddRange(dll_plugins);
}
return ret;
}
}
Just a comment: my solutions (containing interfaces, plugins and test console app) compiled my app in .\bin and plugins in .\bin\Plugins. In both folders was deployed Interfaces.dll on which my projects rely on. This is a serious problem, remember (read comments in code) !!!
So you can compile your plugins avoiding that Interfaces.dll is copied in .\bin\Plugins dir; but if you forget this your app won't work at all; so I decided to force dll deletion before searching and loading plugins.

Writing C# Plugin System

I'm trying to write a plugin system to provide some extensibility to an application of mine so someone can write a plugin(s) for the application without touching the main application's code (and risk breaking something).
I've got the base "IPlugin" interface written (atm, nothing is implemented yet)
Here is how I'm loading:
public static void Load()
{
// rawr: http://www.codeproject.com/KB/cs/c__plugin_architecture.aspx
String[] pluginFiles = Directory.GetFiles(Plugins.PluginsDirectory, "*.dll");
foreach (var plugin in pluginFiles)
{
Type objType = null;
try
{
//Assembly.GetExecutingAssembly().GetName().Name
MessageBox.Show(Directory.GetCurrentDirectory());
Assembly asm = Assembly.Load(plugin);
if (asm != null)
{
objType = asm.GetType(asm.FullName);
if (objType != null)
{
if (typeof(IPlugin).IsAssignableFrom(objType))
{
MessageBox.Show(Directory.GetCurrentDirectory());
IPlugin ipi = (IPlugin)Activator.CreateInstance(objType);
ipi.Host = Plugins.m_PluginsHost;
ipi.Assembly = asm;
}
}
}
}
catch (Exception e)
{
MessageBox.Show(e.ToString(), "Unhandled Exception! (Please Report!)", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information);
}
}
}
A friend tried to help but I really didn't understand what was wrong.
The folder structure for plugins is the following:
\
\Plugins\
All plugins reference a .dll called "Lab.Core.dll" in the [root] directory and it is not present in the Plugins directory because of duplicate references being loaded.
The plugin system is loaded from Lab.Core.dll which is also referenced by my executable. Type "IPlugin" is in Lab.Core.dll as well. Lab.Core.dll is, exactly as named, the core of my application.
EDIT:
Question: Why/What is that exception I'm getting and how could I go about fixing it?
FINAL EDIT:
Ok so I decided to re-write it after looking at some source code a friend wrote for a TF2 regulator.
Here's what I got and it works:
public class TestPlugin : IPlugin {
#region Constructor
public TestPlugin() {
//
}
#endregion
#region IPlugin Members
public String Name {
get {
return "Test Plugin";
}
}
public String Version {
get {
return "1.0.0";
}
}
public String Author {
get {
return "Zack";
}
}
public Boolean OnLoad() {
MessageBox.Show("Loaded!");
return true;
}
public Boolean OnAllLoaded() {
MessageBox.Show("All loaded!");
return true;
}
#endregion
}
public static void Load(String file) {
if (!File.Exists(file) || !file.EndsWith(".dll", true, null))
return;
Assembly asm = null;
try {
asm = Assembly.LoadFile(file);
} catch (Exception) {
// unable to load
return;
}
Type pluginInfo = null;
try {
Type[] types = asm.GetTypes();
Assembly core = AppDomain.CurrentDomain.GetAssemblies().Single(x => x.GetName().Name.Equals("Lab.Core"));
Type type = core.GetType("Lab.Core.IPlugin");
foreach (var t in types)
if (type.IsAssignableFrom((Type)t)) {
pluginInfo = t;
break;
}
if (pluginInfo != null) {
Object o = Activator.CreateInstance(pluginInfo);
IPlugin plugin = (IPlugin)o;
Plugins.Register(plugin);
}
} catch (Exception) {
}
}
public static void LoadAll() {
String[] files = Directory.GetFiles("./Plugins/", "*.dll");
foreach (var s in files)
Load(Path.Combine(Environment.CurrentDirectory, s));
for (Int32 i = 0; i < Plugins.List.Count; ++i) {
IPlugin p = Plugins.List.ElementAt(i);
try {
if (!p.OnAllLoaded()) {
Plugins.List.RemoveAt(i);
--i;
}
} catch (Exception) {
Plugins.List.RemoveAt(i);
--i;
}
}
}
The Managed Extensibility Framework (MEF) is a new library in .NET that enables greater reuse of applications and components. Using MEF, .NET applications can make the shift from being statically compiled to dynamically composed. If you are building extensible applications, extensible frameworks and application extensions, then MEF is for you.
http://www.codeplex.com/MEF
Edit: CodePlex is going away - the code has been moved to Github for archival purposes only: https://github.com/MicrosoftArchive/mef
MEF is now a part of the Microsoft .NET Framework, with types primarily under the System.Composition. namespaces. There are two versions of MEF
System.ComponentModel.Composition, which has shipped with .NET 4.0 and higher. This provides the standard extension model that has been used in Visual Studio. The documentation for this version of MEF can be found here
System.Compostion is a lightweight version of MEF, which has been optimized for static composition scenarios and provides faster compositions. It is also the only version of MEF that is a portable class library and can be used on phone, store, desktop and web applications. This version of MEF is available via NuGet and is documentation is available here
It sounds like you have a circular reference. You said your plugins reference Lab.Core.DLL, but you also say the plugins are loaded from Lab.Core.DLL.
Am I misunderstanding what is happening here?
EDIT: OK now that you have added your question to the question...
You need to have Lab.Core.DLL accessible to the plugin being loaded since it is a dependency. Normally that would mean having it in the same directory or in the GAC.
I suspect there are deeper design issues at play here, but this is your immediate problem.
As a side answer, i use these 2 interfaces for implementing that
public interface IPlugin {
string Name { get; }
string Description { get; }
string Author { get; }
string Version { get; }
IPluginHost Host { get; set; }
void Init();
void Unload();
IDictionary<int, string> GetOptions();
void ExecuteOption(int option);
}
public interface IPluginHost {
IDictionary<string, object> Variables { get; }
void Register(IPlugin plugin);
}

Publishing to multiple subscribes in RX

I am investigating how to develop a plugin framework for a project and Rx seems like a good fit for what i am trying to achieve. Ultimately, the project will be a set of plugins (modular functionality) that can be configured via xml to do different things. The requirements are as follows
Enforce a modular architecture even within a plugin. This encourages loose coupling and potentially minimizes complexity. This hopefully should make individual plugin functionality easier to model and test
Enforce immutability with respect to data to reduce complexity and ensure that state management within modules is kept to a minimum
Discourage manual thread creation by providing thread pool threads to do work within modules wherever possible
In my mind, a plugin is essentially a data transformation entity. This means a plugin either
Takes in some data and transforms it in some way to produce new data (Not shown here)
Generates data in itself and pushes it out to observers
Takes in some data and does some work on the data without notifying outsiders
If you take the concept further, a plugin can consist of a number of all three types above.For example within a plugin you can have an IntGenerator module that generates some data to a ConsoleWorkUnit module etc. So what I am trying to model in the main function is the wiring that a plugin would have to do its work.
To that end, I have the following base classes using the Immutable nuget from Microsoft. What I am trying to achieve is to abstract away the Rx calls so they can be used in modules so the ultimate aim would be to wrap up calls to buffer etc in abstract classes that can be used to compose complex queries and modules. This way the code is a bit more self documenting than having to actually read all the code within a module to find out it subscribes to a buffer or window of type x etc.
public abstract class OutputBase<TOutput> : SendOutputBase<TOutput>
{
public abstract void Work();
}
public interface IBufferedBase<TOutput>
{
void Work(IList<ImmutableList<Data<TOutput>>> list);
}
public abstract class BufferedWorkBase<TInput> : IBufferedBase<TInput>
{
public abstract void Work(IList<ImmutableList<Data<TInput>>> input);
}
public abstract class SendOutputBase<TOutput>
{
private readonly ReplaySubject<ImmutableList<Data<TOutput>>> _outputNotifier;
private readonly IObservable<ImmutableList<Data<TOutput>>> _observable;
protected SendOutputBase()
{
_outputNotifier = new ReplaySubject<ImmutableList<Data<TOutput>>>(10);
_observable = _outputNotifier.SubscribeOn(ThreadPoolScheduler.Instance);
_observable = _outputNotifier.ObserveOn(ThreadPoolScheduler.Instance);
}
protected void SetOutputTo(ImmutableList<Data<TOutput>> output)
{
_outputNotifier.OnNext(output);
}
public void ConnectOutputTo(IWorkBase<TOutput> unit)
{
_observable.Subscribe(unit.Work);
}
public void BufferOutputTo(int count, IBufferedBase<TOutput> unit)
{
_observable.Buffer(count).Subscribe(unit.Work);
}
}
public abstract class WorkBase<TInput> : IWorkBase<TInput>
{
public abstract void Work(ImmutableList<Data<TInput>> input);
}
public interface IWorkBase<TInput>
{
void Work(ImmutableList<Data<TInput>> input);
}
public class Data<T>
{
private readonly T _value;
private Data(T value)
{
_value = value;
}
public static Data<TData> Create<TData>(TData value)
{
return new Data<TData>(value);
}
public T Value { get { return _value; } }
}
These base classes are used to create three classes; one for generating some int data, one to print out the data when they occur and the last to buffer the data as it comes in and sum the values in threes.
public class IntGenerator : OutputBase<int>
{
public override void Work()
{
var list = ImmutableList<Data<int>>.Empty;
var builder = list.ToBuilder();
for (var i = 0; i < 1000; i++)
{
builder.Add(Data<int>.Create(i));
}
SetOutputTo(builder.ToImmutable());
}
}
public class ConsoleWorkUnit : WorkBase<int>
{
public override void Work(ImmutableList<Data<int>> input)
{
foreach (var data in input)
{
Console.WriteLine("ConsoleWorkUnit printing {0}", data.Value);
}
}
}
public class SumPrinter : WorkBase<int>
{
public override void Work(ImmutableList<Data<int>> input)
{
input.ToObservable().Buffer(2).Subscribe(PrintSum);
}
private void PrintSum(IList<Data<int>> obj)
{
Console.WriteLine("Sum of {0}, {1} is {2} ", obj.First().Value,obj.Last().Value ,obj.Sum(x=>x.Value) );
}
}
These are run in a main like this
var intgen = new IntGenerator();
var cons = new ConsoleWorkUnit();
var sumPrinter = new SumPrinter();
intgen.ConnectOutputTo(cons);
intgen.BufferOutputTo(3,sumPrinter);
Task.Factory.StartNew(intgen.Work);
Console.ReadLine();
Is this architecture sound?
You are buffering your observable (.Buffer(count)) so that it only signals after count notifications arrive.
However, your IntGenerator.DoWork only ever produces a single value. Thus you never "fill" the buffer and trigger downstream notifications.
Either change DoWork so that it eventually produces more values, or have it complete the observable stream when it finishes its work. Buffer will release the remaining buffered values when the stream completes. To do this, it means somewhere IntGenerator.DoWork needs to cause a call to _outputNotifier.OnCompleted()

Categories

Resources