diff --git a/src/Agent.Listener/MessageDispatcher.cs b/src/Agent.Listener/MessageDispatcher.cs deleted file mode 100644 index 88560fac57..0000000000 --- a/src/Agent.Listener/MessageDispatcher.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Diagnostics; -using Microsoft.TeamFoundation.DistributedTask.WebApi; -using Microsoft.VisualStudio.Services.Agent; - -namespace Microsoft.VisualStudio.Services.Agent.Listener -{ - [ServiceLocator(Default = typeof(MessageDispatcher))] - public interface IMessageDispatcher: IAgentService - { - void Dispatch(TaskAgentMessage message); - } - - public sealed class MessageDispatcher : AgentService, IMessageDispatcher - { - - // AgentRefreshMessage.MessageType - // JobCancelMessage.MessageType - // JobRequestMessage.MessageType - public void Dispatch(TaskAgentMessage message) - { - throw new System.NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/src/Agent.Listener/MessageListener.cs b/src/Agent.Listener/MessageListener.cs index 745894cf42..ec9d076361 100644 --- a/src/Agent.Listener/MessageListener.cs +++ b/src/Agent.Listener/MessageListener.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics; -using System.Threading; using System.Threading.Tasks; using Microsoft.TeamFoundation.DistributedTask.WebApi; using Microsoft.VisualStudio.Services.Agent.Configuration; @@ -77,7 +76,7 @@ public async Task CreateSessionAsync() } return false; - } + } public async Task ListenAsync() { @@ -85,74 +84,82 @@ public async Task ListenAsync() { throw new InvalidOperationException("Must create a session before listening"); } - - Debug.Assert(_settings != null, "settings should not be null"); - - var dispatcher = HostContext.GetService(); + Debug.Assert(_settings != null, "settings should not be null"); var taskServer = HostContext.GetService(); - - long? lastMessageId = null; - while (true) + //TODO: Interaction with the WorkerManager is the responsibility of the caller. Listener just returns the message. + using (var workerManager = HostContext.GetService()) { - TaskAgentMessage message = null; - try - { - message = await taskServer.GetAgentMessageAsync(_settings.PoolId, - Session.SessionId, - lastMessageId, - HostContext.CancellationToken); - } - catch (TimeoutException) - { - Trace.Verbose("MessageListener.Listen - TimeoutException received."); - } - catch (TaskCanceledException) - { - Trace.Verbose("MessageListener.Listen - TaskCanceledException received."); - } - catch (TaskAgentSessionExpiredException) - { - Trace.Verbose("MessageListener.Listen - TaskAgentSessionExpiredException received."); - // TODO: Throw a specific exception so the caller can control the flow appropriately. - return; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Trace.Verbose("MessageListener.Listen - Exception received."); - Trace.Error(ex); - // TODO: Throw a specific exception so the caller can control the flow appropriately. - return; - } - - if (message == null) - { - Trace.Verbose("MessageListener.Listen - No message retrieved from session '{0}'.", this.Session.SessionId); - continue; - } - Trace.Verbose("MessageListener.Listen - Message '{0}' received from session '{1}'.", message.MessageId, this.Session.SessionId); - try + long? lastMessageId = null; + while (true) { - // Check if refresh is required. - if (String.Equals(message.MessageType, AgentRefreshMessage.MessageType, StringComparison.OrdinalIgnoreCase)) + TaskAgentMessage message = null; + try { - // Throw a specific exception so the caller can control the flow appropriately. + message = await taskServer.GetAgentMessageAsync(_settings.PoolId, + Session.SessionId, + lastMessageId, + HostContext.CancellationToken); + } + catch (TimeoutException) + { + Trace.Verbose("MessageListener.Listen - TimeoutException received."); + } + catch (TaskCanceledException) + { + Trace.Verbose("MessageListener.Listen - TaskCanceledException received."); + } + catch (TaskAgentSessionExpiredException) + { + Trace.Verbose("MessageListener.Listen - TaskAgentSessionExpiredException received."); + // TODO: Throw a specific exception so the caller can control the flow appropriately. + return; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Trace.Warning("MessageListener.Listen - Exception received."); + Trace.Error(ex); + // TODO: Throw a specific exception so the caller can control the flow appropriately. return; } + + if (message == null) + { + Trace.Verbose("MessageListener.Listen - No message retrieved from session '{0}'.", this.Session.SessionId); + continue; + } - dispatcher.Dispatch(message); - } - finally - { - lastMessageId = message.MessageId; - await taskServer.DeleteAgentMessageAsync(_settings.PoolId, - lastMessageId.Value, - Session.SessionId, - HostContext.CancellationToken); + Trace.Verbose("MessageListener.Listen - Message '{0}' received from session '{1}'.", message.MessageId, this.Session.SessionId); + try + { + // Check if refresh is required. + if (String.Equals(message.MessageType, AgentRefreshMessage.MessageType, StringComparison.OrdinalIgnoreCase)) + { + Trace.Warning("Referesh message received, but not yet handled by agent implementation."); + } + else if (String.Equals(message.MessageType, JobRequestMessage.MessageType, StringComparison.OrdinalIgnoreCase)) + { + var newJobMessage = JsonUtility.FromString(message.Body); + await workerManager.Run(newJobMessage); + } + else if (String.Equals(message.MessageType, JobCancelMessage.MessageType, StringComparison.OrdinalIgnoreCase)) + { + var cancelJobMessage = JsonUtility.FromString(message.Body); + await workerManager.Cancel(cancelJobMessage); + } + } + finally + { + lastMessageId = message.MessageId; + await taskServer.DeleteAgentMessageAsync(_settings.PoolId, + lastMessageId.Value, + Session.SessionId, + HostContext.CancellationToken); + } } } } diff --git a/src/Agent.Listener/Program.cs b/src/Agent.Listener/Program.cs index 0b12b39187..7c332d6541 100644 --- a/src/Agent.Listener/Program.cs +++ b/src/Agent.Listener/Program.cs @@ -31,7 +31,7 @@ public static Int32 Main(String[] args) Int32 rc = 0; try { - rc = ExecuteCommand(context, parser).Result; + rc = ExecuteCommand(context, parser).Result; } catch (Exception e) { @@ -108,30 +108,19 @@ private static async Task ExecuteCommand(HostContext context, CommandLine //_trace.Info("Worker.exe Exit: {0}", exitCode); ICredentialProvider cred = configManager.AcquireCredentials(parser.Args, isUnattended); - return RunAsync(context).Result; + return await RunAsync(context); } public static async Task RunAsync(IHostContext context) - { - /* - try - { - var listener = context.GetService(); - if (await listener.CreateSessionAsync()) - { - await listener.ListenAsync(); - } - - await listener.DeleteSessionAsync(); - } - catch (Exception) + { + var listener = context.GetService(); + if (await listener.CreateSessionAsync()) { - // TODO: Log exception. - return 1; + await listener.ListenAsync(); } - */ - return 0; + await listener.DeleteSessionAsync(); + return 0; } private static void PrintUsage() diff --git a/src/Agent.Listener/Worker.cs b/src/Agent.Listener/Worker.cs new file mode 100644 index 0000000000..ac29bde98d --- /dev/null +++ b/src/Agent.Listener/Worker.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.Services.Agent.Util; +using System.IO; + +namespace Microsoft.VisualStudio.Services.Agent.Listener +{ + public enum WorkerState + { + New, + Starting, + Finished, + } + + [ServiceLocator(Default = typeof(Worker))] + public interface IWorker : IDisposable, IAgentService + { + event EventHandler StateChanged; + Guid JobId { get; set; } + IProcessChannel ProcessChannel { get; set; } + //TODO: instead of LaunchProcess, do something like Task RunAsync(...) and make sure you take a cancellation token. The way the IWorkerManager can handle cancelling the worker is to simply signal the cancellation token that it handed to the IWorker.RunAsync method. + void LaunchProcess(String pipeHandleOut, String pipeHandleIn, string workingFolder); + } + + public class Worker : AgentService, IWorker + { +#if OS_WINDOWS + private const String WorkerProcessName = "Agent.Worker.exe"; +#else + private const String WorkerProcessName = "Agent.Worker"; +#endif + + public event EventHandler StateChanged; + public Guid JobId { get; set; } + public IProcessChannel ProcessChannel { get; set; } + private IProcessInvoker _processInvoker; + private WorkerState _state; + public WorkerState State + { + get + { + return _state; + } + private set + { + if (value != _state) + { + _state = value; + if (null != StateChanged) + { + StateChanged(this, null); + } + } + } + } + public Worker() + { + State = WorkerState.New; + } + + public void LaunchProcess(String pipeHandleOut, String pipeHandleIn, string workingFolder) + { + string workerFileName = Path.Combine(AssemblyUtil.AssemblyDirectory, WorkerProcessName); + _processInvoker = HostContext.GetService(); + _processInvoker.Exited += _processInvoker_Exited; + State = WorkerState.Starting; + var environmentVariables = new Dictionary(); + _processInvoker.Execute(workingFolder, workerFileName, "spawnclient " + pipeHandleOut + " " + pipeHandleIn, + environmentVariables); + } + + private void _processInvoker_Exited(object sender, EventArgs e) + { + _processInvoker.Exited -= _processInvoker_Exited; + if (null != ProcessChannel) + { + ProcessChannel.Dispose(); + ProcessChannel = null; + } + State = WorkerState.Finished; + } + +#region IDisposable Support + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (null != ProcessChannel) + { + ProcessChannel.Dispose(); + ProcessChannel = null; + } + if (null != _processInvoker) + { + _processInvoker.Dispose(); + _processInvoker = null; + } + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } +#endregion + } +} diff --git a/src/Agent.Listener/WorkerManager.cs b/src/Agent.Listener/WorkerManager.cs new file mode 100644 index 0000000000..8918ce7d5b --- /dev/null +++ b/src/Agent.Listener/WorkerManager.cs @@ -0,0 +1,86 @@ +using Microsoft.TeamFoundation.DistributedTask.WebApi; +using Microsoft.VisualStudio.Services.Agent.Util; +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Services.Agent.Listener +{ + [ServiceLocator(Default = typeof(WorkerManager))] + public interface IWorkerManager : IDisposable, IAgentService + { + Task Run(JobRequestMessage message); + Task Cancel(JobCancelMessage message); + } + + public class WorkerManager : AgentService, IWorkerManager + { + private ConcurrentDictionary _jobsInProgress = new ConcurrentDictionary(); + + public async Task Run(JobRequestMessage jobRequestMessage) + { + Trace.Info("Job request {0} received.", jobRequestMessage.JobId); + var worker = HostContext.GetService(); + worker.JobId = jobRequestMessage.JobId; + //we should always create a IProcessChannel, and not use a singleton + worker.ProcessChannel = HostContext.GetService(); + worker.StateChanged += Worker_StateChanged; + _jobsInProgress[jobRequestMessage.JobId] = worker; + worker.ProcessChannel.StartServer( (pipeHandleOut, pipeHandleIn) => + { + worker.LaunchProcess(pipeHandleOut, pipeHandleIn, AssemblyUtil.AssemblyDirectory); + } + ); + await worker.ProcessChannel.SendAsync(jobRequestMessage, HostContext.CancellationToken); + } + + private void Worker_StateChanged(object sender, EventArgs e) + { + var worker = sender as Worker; + if (worker.State == WorkerState.Finished) + { + IWorker deletedJob; + if (_jobsInProgress.TryRemove(worker.JobId, out deletedJob)) + { + deletedJob.StateChanged -= Worker_StateChanged; + deletedJob.Dispose(); + } + } + } + + public async Task Cancel(JobCancelMessage jobCancelMessage) + { + IWorker worker = null; + if (!_jobsInProgress.TryGetValue(jobCancelMessage.JobId, out worker)) + { + Trace.Error("Received cancellation for invalid job id {0}.", jobCancelMessage.JobId); + } + await worker.ProcessChannel.SendAsync(jobCancelMessage, HostContext.CancellationToken); + } + + #region IDisposable Support + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + foreach (var item in _jobsInProgress) + { + item.Value.StateChanged -= Worker_StateChanged; + item.Value.Dispose(); + } + + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + } +} diff --git a/src/Agent.Listener/_project.json b/src/Agent.Listener/_project.json index 3c7c118cac..c53984c1c6 100644 --- a/src/Agent.Listener/_project.json +++ b/src/Agent.Listener/_project.json @@ -32,7 +32,7 @@ "dependencies": { "NETStandard.Library": "1.0.0-rc3-23721", "Microsoft.VisualStudio.Services.Agent": "", - "Newtonsoft.Json": "8.0.2", + "Newtonsoft.Json": "7.0.1", "System.Diagnostics.TraceSource": "4.0.0-rc3-23721" }, diff --git a/src/Agent.Worker/JobRunner.cs b/src/Agent.Worker/JobRunner.cs index 2a3c9668b3..57784d5f6c 100644 --- a/src/Agent.Worker/JobRunner.cs +++ b/src/Agent.Worker/JobRunner.cs @@ -1,5 +1,8 @@ +using Microsoft.TeamFoundation.DistributedTask.WebApi; using System; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.VisualStudio.Services.Agent.Worker { @@ -9,8 +12,8 @@ public JobRunner(IHostContext hostContext) { m_hostContext = hostContext; m_trace = hostContext.GetTrace("JobRunner"); } - - public void Run() + + public async Task Run(JobRequestMessage message) { ExecutionContext context = new ExecutionContext(m_hostContext); m_trace.Verbose("Prepare"); @@ -24,9 +27,19 @@ public void Run() m_trace.Verbose("Finish"); context.LogInfo("Finish..."); context.LogVerbose("Finishing..."); + + m_trace.Info("Job id {0}", message.JobId); + + m_finishedSignal.Release(); } - + + public Task WaitToFinish(IHostContext context) + { + return m_finishedSignal.WaitAsync(context.CancellationToken); + } + private IHostContext m_hostContext; private readonly TraceSource m_trace; + private SemaphoreSlim m_finishedSignal = new SemaphoreSlim(0, 1); } } diff --git a/src/Agent.Worker/Program.cs b/src/Agent.Worker/Program.cs index 02c2bdde42..d66d644275 100644 --- a/src/Agent.Worker/Program.cs +++ b/src/Agent.Worker/Program.cs @@ -1,38 +1,79 @@ using System; using System.Diagnostics; using Microsoft.VisualStudio.Services.Agent; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.TeamFoundation.DistributedTask.WebApi; namespace Microsoft.VisualStudio.Services.Agent.Worker { public static class Program { - public static void Main(string[] args) + public static Int32 Main(string[] args) { - HostContext hc = new HostContext("Worker"); - Console.WriteLine("Hello Worker!"); - + return RunAsync(args).GetAwaiter().GetResult(); + } + + public static async Task RunAsync(string[] args) + { + using (HostContext hc = new HostContext("Worker")) + { + Console.WriteLine("Hello Worker!"); + #if OS_WINDOWS - Console.WriteLine("Hello Windows"); + Console.WriteLine("Hello Windows"); #endif - + #if OS_OSX - Console.WriteLine("Hello OSX"); + Console.WriteLine("Hello OSX"); #endif #if OS_LINUX - Console.WriteLine("Hello Linux"); + Console.WriteLine("Hello Linux"); #endif - TraceSource m_trace = hc.GetTrace("WorkerProcess"); - m_trace.Info("Info Hello Worker!"); - m_trace.Warning("Warning Hello Worker!"); - m_trace.Error("Error Hello Worker!"); - m_trace.Verbose("Verbos Hello Worker!"); - - JobRunner jobRunner = new JobRunner(hc); - jobRunner.Run(); - - hc.Dispose(); + TraceSource m_trace = hc.GetTrace("WorkerProcess"); + m_trace.Info("Info Hello Worker!"); + m_trace.Warning("Warning Hello Worker!"); + m_trace.Error("Error Hello Worker!"); + m_trace.Verbose("Verbos Hello Worker!"); + + //TODO: Consider removing events and use receive methods in ProcessChannel and StreamTransport + JobRunner jobRunner = null; + Func cancelHandler = (message, token) => + { + hc.CancellationTokenSource.Cancel(); + return Task.CompletedTask; + }; + + Func newRequestHandler = async (message, token) => + { + await jobRunner.Run(message); + }; + + try { + if (null != args && 3 == args.Length && "spawnclient".Equals(args[0].ToLower())) + { + using (var channel = hc.GetService()) + { + channel.JobRequestMessageReceived += newRequestHandler; + channel.JobCancelMessageReceived += cancelHandler; + jobRunner = new JobRunner(hc); + channel.StartClient(args[1], args[2]); + await jobRunner.WaitToFinish(hc); + channel.JobRequestMessageReceived -= newRequestHandler; + channel.JobCancelMessageReceived -= cancelHandler; + await channel.Stop(); + } + } + } + catch (Exception ex) + { + m_trace.Error(ex); + return 1; + } + } + return 0; } } } diff --git a/src/Microsoft.VisualStudio.Services.Agent/Extensions.cs b/src/Microsoft.VisualStudio.Services.Agent/Extensions.cs new file mode 100644 index 0000000000..37ea2294a9 --- /dev/null +++ b/src/Microsoft.VisualStudio.Services.Agent/Extensions.cs @@ -0,0 +1,31 @@ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Services.Agent +{ + //this code is documented on http://blogs.msdn.com/b/pfxteam/archive/2012/10/05/how-do-i-cancel-non-cancelable-async-operations.aspx + public static class Extensions + { + public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + using (cancellationToken.Register( + s => ((TaskCompletionSource)s).TrySetResult(true), tcs)) + if (task != await Task.WhenAny(task, tcs.Task)) + throw new OperationCanceledException(cancellationToken); + return await task; + } + + public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + using (cancellationToken.Register( + s => ((TaskCompletionSource)s).TrySetResult(true), tcs)) + if (task != await Task.WhenAny(task, tcs.Task)) + throw new OperationCanceledException(cancellationToken); + await task; + } + } +} diff --git a/src/Microsoft.VisualStudio.Services.Agent/HostContext.cs b/src/Microsoft.VisualStudio.Services.Agent/HostContext.cs index e18bf13a9f..f3327d443e 100644 --- a/src/Microsoft.VisualStudio.Services.Agent/HostContext.cs +++ b/src/Microsoft.VisualStudio.Services.Agent/HostContext.cs @@ -22,7 +22,7 @@ public interface IHostContext public sealed class HostContext : IHostContext, IDisposable { private readonly ConcurrentDictionary serviceMappings = new ConcurrentDictionary(); - private readonly CancellationToken m_cancellationToken = new CancellationToken(); + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private ITraceManager _traceManager; private String _hostType; @@ -40,7 +40,16 @@ public CancellationToken CancellationToken { get { - return m_cancellationToken; + return _cancellationTokenSource.Token; + } + } + + //TODO: hide somehow this variable + public CancellationTokenSource CancellationTokenSource + { + get + { + return _cancellationTokenSource; } } diff --git a/src/Microsoft.VisualStudio.Services.Agent/ProcessChannel.cs b/src/Microsoft.VisualStudio.Services.Agent/ProcessChannel.cs new file mode 100644 index 0000000000..142f00f5d6 --- /dev/null +++ b/src/Microsoft.VisualStudio.Services.Agent/ProcessChannel.cs @@ -0,0 +1,179 @@ +using Microsoft.TeamFoundation.DistributedTask.WebApi; +using System; +using System.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Services.Agent +{ + [ServiceLocator(Default = typeof(ProcessChannel))] + public interface IProcessChannel : IDisposable, IAgentService + { + event Func JobRequestMessageReceived; + event Func JobCancelMessageReceived; + + Task SendAsync(JobRequestMessage jobRequest, CancellationToken cancellationToken); + Task SendAsync(JobCancelMessage jobCancel, CancellationToken cancellationToken); + void StartServer(ProcessStartDelegate processStart); + void StartClient(string pipeNameInput, string pipeNameOutput); + Task Stop(); + } + + public delegate void ProcessStartDelegate(String pipeHandleOut, String pipeHandleIn); + + public class ProcessChannel : AgentService, IProcessChannel + { + private Task RunTask; + private CancellationTokenSource TokenSource; + private AnonymousPipeServerStream InServer; + private AnonymousPipeServerStream OutServer; + private AnonymousPipeClientStream InClient; + private AnonymousPipeClientStream OutClient; + private volatile bool Started = false; + private StreamTransport Transport { get; set; } + + public ProcessChannel() + { + Transport = new StreamTransport(); + TokenSource = new CancellationTokenSource(); + } + + public void StartServer(ProcessStartDelegate processStart) + { + if (!Started) + { + Started = true; + OutServer = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable); + InServer = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); + Transport.ReadPipe = InServer; + Transport.WritePipe = OutServer; + processStart(OutServer.GetClientHandleAsString(), InServer.GetClientHandleAsString()); + OutServer.DisposeLocalCopyOfClientHandle(); + InServer.DisposeLocalCopyOfClientHandle(); + Transport.PacketReceived += Transport_PacketReceived; + RunTask = Transport.Run(TokenSource.Token); + } + } + + public void StartClient(string pipeNameInput, string pipeNameOutput) + { + if (!Started) + { + Started = true; + InClient = new AnonymousPipeClientStream(PipeDirection.In, pipeNameInput); + OutClient = new AnonymousPipeClientStream(PipeDirection.Out, pipeNameOutput); + Transport.ReadPipe = InClient; + Transport.WritePipe = OutClient; + Transport.PacketReceived += Transport_PacketReceived; + RunTask = Transport.Run(TokenSource.Token); + } + } + + public async Task Stop() + { + if (Started) + { + Started = false; + Transport.PacketReceived -= Transport_PacketReceived; + TokenSource.Cancel(); + + try + { + await RunTask; + } + catch (OperationCanceledException) + { + // Ignore OperationCanceledException and TaskCanceledException exceptions + } + catch (AggregateException errors) + { + // Ignore OperationCanceledException and TaskCanceledException exceptions + errors.Handle(e => e is OperationCanceledException); + } + } + } + + private async Task Transport_PacketReceived(IPCPacket packet, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + switch (packet.MessageType) + { + case 1: + { + var message = JsonUtility.FromString(packet.Body); + Func jobRequestMessageReceived = JobRequestMessageReceived; + if (null == jobRequestMessageReceived) + { + return; + } + Delegate[] invocationList = jobRequestMessageReceived.GetInvocationList(); + Task[] handlerTasks = new Task[invocationList.Length]; + for (int i = 0; i < invocationList.Length; i++) + { + handlerTasks[i] = ((Func)invocationList[i])(message, token); + } + await Task.WhenAll(handlerTasks); + } + break; + case 2: + { + var message = JsonUtility.FromString(packet.Body); + Func jobCancelMessageReceived = JobCancelMessageReceived; + if (null == jobCancelMessageReceived) + { + return; + } + Delegate[] invocationList = jobCancelMessageReceived.GetInvocationList(); + Task[] handlerTasks = new Task[invocationList.Length]; + for (int i = 0; i < invocationList.Length; i++) + { + handlerTasks[i] = ((Func)invocationList[i])(message, token); + } + await Task.WhenAll(handlerTasks); + } + break; + } + } + + public event Func JobRequestMessageReceived; + public event Func JobCancelMessageReceived; + + public async Task SendAsync(JobRequestMessage jobRequest, CancellationToken cancellationToken) + { + string messageString = JsonUtility.ToString(jobRequest); + await Transport.SendAsync(1, messageString, cancellationToken); + } + + public async Task SendAsync(JobCancelMessage jobCancel, CancellationToken cancellationToken) + { + string messageString = JsonUtility.ToString(jobCancel); + await Transport.SendAsync(2, messageString, cancellationToken); + } + + #region IDisposable Support + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + TokenSource.Dispose(); + if (null != InServer) InServer.Dispose(); + if (null != OutServer) OutServer.Dispose(); + if (null != InClient) InClient.Dispose(); + if (null != OutClient) OutClient.Dispose(); + disposedValue = true; + } + } + + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + } +} diff --git a/src/Microsoft.VisualStudio.Services.Agent/ProcessInvoker.cs b/src/Microsoft.VisualStudio.Services.Agent/ProcessInvoker.cs index 46850c5755..f26f2d333c 100644 --- a/src/Microsoft.VisualStudio.Services.Agent/ProcessInvoker.cs +++ b/src/Microsoft.VisualStudio.Services.Agent/ProcessInvoker.cs @@ -1,98 +1,187 @@ +using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.VisualStudio.Services.Agent { - public static class ProcessInvoker + public class ProcessDataReceivedEventArgs : EventArgs { - public static int RunExe(IHostContext hostContext, string filename, string arguments) + public ProcessDataReceivedEventArgs(String data) { - TraceSource _trace = hostContext.GetTrace("ProcessInvoker"); - _trace.Info("Starting process {0} {1}", filename, arguments); + Data = data; + } + + public String Data { get; private set; } + } + + [ServiceLocator(Default = typeof(ProcessInvoker))] + public interface IProcessInvoker : IDisposable, IAgentService + { + event EventHandler OutputDataReceived; + + event EventHandler ErrorDataReceived; + + event EventHandler Exited; + + void Execute(String workingFolder, String filename, String arguments, IDictionary environmentVariables); - ProcessStartInfo processStartInfo = new ProcessStartInfo() + Task WaitForExit(); + } + + public class ProcessInvoker : AgentService, IProcessInvoker + { + private Process _proc; + private SemaphoreSlim _processExitedSignal = new SemaphoreSlim(0, 1); + private Stopwatch _stopWatch; + + public event EventHandler OutputDataReceived; + public event EventHandler ErrorDataReceived; + + public event EventHandler Exited; + + public async Task WaitForExit() + { + while ((!HostContext.CancellationToken.IsCancellationRequested) && (!_proc.HasExited)) { - FileName = filename, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardInput = false, - RedirectStandardError = true, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; + await _processExitedSignal.WaitAsync(TimeSpan.FromSeconds(30), HostContext.CancellationToken); + if (!_proc.HasExited) + { + Trace.Info( + "Waiting on process {0} ({1} seconds elapsed)", + _proc.Id, + _stopWatch.Elapsed.TotalSeconds); + } + } + HostContext.CancellationToken.ThrowIfCancellationRequested(); + // Wait for process to exit without hard timeout, which will + // ensure that we've read everything from the stdout and stderr. + _proc.WaitForExit(); - object syncObject = new object(); - using (Process proc = new Process()) + Trace.Info("Process finished: fileName={0} arguments={1} exitCode={2} in {3} ms", + _proc.StartInfo.FileName, _proc.StartInfo.Arguments, _proc.ExitCode, _stopWatch.ElapsedMilliseconds); + + return _proc.ExitCode; + } + + public void Execute(String workingDirectory, String filename, String arguments, IDictionary environmentVariables) + { + Debug.Assert(null == _proc); + Trace.Info("Starting process {0} {1} on working directory {2}", filename, arguments, workingDirectory); + + _proc = new Process(); + _proc.StartInfo.FileName = filename; + _proc.StartInfo.Arguments = arguments; + _proc.StartInfo.WorkingDirectory = workingDirectory; + _proc.StartInfo.UseShellExecute = false; + _proc.StartInfo.RedirectStandardInput = false; + _proc.StartInfo.RedirectStandardError = null != ErrorDataReceived; + _proc.StartInfo.RedirectStandardOutput = null != OutputDataReceived; + //set the following flag to "false" if you like to see worker console output for debugging + _proc.StartInfo.CreateNoWindow = true; + + if (environmentVariables != null && environmentVariables.Count > 0) { - bool processExited = false; + foreach (KeyValuePair kvp in environmentVariables) + { + _proc.StartInfo.Environment[kvp.Key] = kvp.Value; + } + } + + object syncObject = new object(); - proc.StartInfo = processStartInfo; - proc.EnableRaisingEvents = true; + _proc.EnableRaisingEvents = true; - proc.OutputDataReceived += delegate (object sender, DataReceivedEventArgs e) + if (_proc.StartInfo.RedirectStandardOutput) + { + _proc.OutputDataReceived += delegate (object sender, DataReceivedEventArgs e) { // at the end of the process, the event fires one last time with null if (e.Data != null) { lock (syncObject) { - _trace.Info(e.Data); + EventHandler outputDataReceived = OutputDataReceived; + if (null != outputDataReceived) + { + outputDataReceived(this, new ProcessDataReceivedEventArgs(e.Data)); + } } } }; - proc.ErrorDataReceived += delegate (object sender, DataReceivedEventArgs e) + } + if (_proc.StartInfo.RedirectStandardError) + { + _proc.ErrorDataReceived += delegate (object sender, DataReceivedEventArgs e) { // at the end of the process, the event fires one last time with null if (e.Data != null) { lock (syncObject) { - _trace.Info(e.Data); + EventHandler errorDataReceived = ErrorDataReceived; + if (null != errorDataReceived) + { + errorDataReceived(this, new ProcessDataReceivedEventArgs(e.Data)); + } } } }; + } - proc.Exited += delegate (object sender, System.EventArgs e) - { - processExited = true; - }; - - Stopwatch stopwatch = Stopwatch.StartNew(); - bool newProcessStarted = proc.Start(); - if (!newProcessStarted) + _proc.Exited += delegate (object sender, System.EventArgs e) + { + _stopWatch.Stop(); + _processExitedSignal.Release(); + EventHandler exited = Exited; + if (null != exited) { - _trace.Verbose("Used existing process instead of starting new one for " + filename); + exited(this, null); } - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - - int seconds = 0; - while (!proc.WaitForExit(1000)) - { - seconds++; - if ((seconds % 30) == 0) - { - _trace.Info( - "Waiting on process {0} ({1} seconds elapsed)", - proc.Id, - seconds); - } + }; - if (processExited) - { - break; - } - } + _stopWatch = Stopwatch.StartNew(); + bool newProcessStarted = _proc.Start(); + if (!newProcessStarted) + { + Trace.Verbose("Used existing process instead of starting new one for " + filename); + } + if (_proc.StartInfo.RedirectStandardOutput) { + _proc.BeginOutputReadLine(); + } + if (_proc.StartInfo.RedirectStandardError) + { + _proc.BeginErrorReadLine(); + } + } - // Wait for process to exit without hard timeout, which will - // ensure that we've read everything from the stdout and stderr. - proc.WaitForExit(); - stopwatch.Stop(); + #region IDisposable Support + private bool disposedValue = false; // To detect redundant calls - _trace.Info("Process finished: fileName={0} arguments={1} exitCode={2} in {3} ms", filename, arguments, proc.ExitCode, stopwatch.ElapsedMilliseconds); + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + _processExitedSignal.Dispose(); + if (null != _proc) + { + _proc.Dispose(); + _proc = null; + } - return proc.ExitCode; + disposedValue = true; } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); } + #endregion } + } diff --git a/src/Microsoft.VisualStudio.Services.Agent/StreamString.cs b/src/Microsoft.VisualStudio.Services.Agent/StreamString.cs new file mode 100644 index 0000000000..9fb3507601 --- /dev/null +++ b/src/Microsoft.VisualStudio.Services.Agent/StreamString.cs @@ -0,0 +1,93 @@ +// Defines the data protocol for reading and writing strings on our stream +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Services.Agent +{ + public class StreamString + { + private Stream ioStream; + private UnicodeEncoding streamEncoding; + + public StreamString(Stream ioStream) + { + this.ioStream = ioStream; + streamEncoding = new UnicodeEncoding(); + } + + public async Task ReadInt32Async(CancellationToken cancellationToken) + { + byte[] readBytes = new byte[sizeof(Int32)]; + int dataread = 0; + while (sizeof(Int32) - dataread > 0 && (!cancellationToken.IsCancellationRequested)) + { + Task op = ioStream.ReadAsync(readBytes, dataread, sizeof(Int32) - dataread, cancellationToken); + int newData = 0; + newData = await op.WithCancellation(cancellationToken); + dataread += newData; + if (0 == newData) + { + await Task.Delay(100, cancellationToken); + } + } + cancellationToken.ThrowIfCancellationRequested(); + return BitConverter.ToInt32(readBytes, 0); + } + + public async Task WriteInt32Async(Int32 value, CancellationToken cancellationToken) + { + byte[] int32Bytes = BitConverter.GetBytes(value); + Task op = ioStream.WriteAsync(int32Bytes, 0, sizeof(Int32), cancellationToken); + await op.WithCancellation(cancellationToken); + } + + const Int32 MAX_STRING_SIZE = 50*1000000; + + public async Task ReadStringAsync(CancellationToken cancellationToken) + { + Int32 len = await ReadInt32Async(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (len <= 0 || len > MAX_STRING_SIZE) + { + throw new InvalidDataException(); + } + + byte[] inBuffer = new byte[len]; + int dataread = 0; + while (len - dataread > 0 && (!cancellationToken.IsCancellationRequested)) + { + Task op = ioStream.ReadAsync(inBuffer, dataread, len - dataread, cancellationToken); + int newData = 0; + newData = await op.WithCancellation(cancellationToken); + dataread += newData; + if (0 == newData) + { + await Task.Delay(100, cancellationToken); + } + } + cancellationToken.ThrowIfCancellationRequested(); + return streamEncoding.GetString(inBuffer); + } + + public async Task WriteStringAsync(string outString, CancellationToken cancellationToken) + { + byte[] outBuffer = streamEncoding.GetBytes(outString); + Int32 len = outBuffer.Length; + if (len > MAX_STRING_SIZE) + { + throw new ArgumentOutOfRangeException(); + } + await WriteInt32Async(len, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + Task op = ioStream.WriteAsync(outBuffer, 0, len, cancellationToken); + await op.WithCancellation(cancellationToken); + op = ioStream.FlushAsync(cancellationToken); + await op.WithCancellation(cancellationToken); + return outBuffer.Length + sizeof(Int32); + } + } + +} \ No newline at end of file diff --git a/src/Microsoft.VisualStudio.Services.Agent/StreamTransport.cs b/src/Microsoft.VisualStudio.Services.Agent/StreamTransport.cs new file mode 100644 index 0000000000..24fd9fad5e --- /dev/null +++ b/src/Microsoft.VisualStudio.Services.Agent/StreamTransport.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Services.Agent +{ + public struct IPCPacket + { + public Int32 MessageType; + public string Body; + public IPCPacket(Int32 p1, string p2) + { + MessageType = p1; + Body = p2; + } + } + + public class StreamTransport + { + public event Func PacketReceived; + + public Stream ReadPipe + { + set + { + ReadStream = new StreamString(value); + } + } + public Stream WritePipe + { + set + { + WriteStream = new StreamString(value); + } + } + + private StreamString WriteStream { get; set; } + + private StreamString ReadStream { get; set; } + + public async Task SendAsync(Int32 MessageType, string Body, CancellationToken cancellationToken) + { + await WriteStream.WriteInt32Async(MessageType, cancellationToken); + await WriteStream.WriteStringAsync(Body, cancellationToken); + } + + public async Task ReceiveAsync(CancellationToken cancellationToken) + { + IPCPacket result = new IPCPacket(-1, ""); + result.MessageType = await ReadStream.ReadInt32Async(cancellationToken); + result.Body = await ReadStream.ReadStringAsync(cancellationToken); + return result; + } + + public async Task Run(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + var packet = await ReceiveAsync(token); + Func packetReceived = PacketReceived; + if (null == packetReceived) + { + continue; + } + Delegate[] invocationList = packetReceived.GetInvocationList(); + Task[] handlerTasks = new Task[invocationList.Length]; + for (int i = 0; i < invocationList.Length; i++) + { + handlerTasks[i] = ((Func)invocationList[i])(packet, token); + } + await Task.WhenAll(handlerTasks); + } + } + } +} diff --git a/src/Microsoft.VisualStudio.Services.Agent/TaskServer.cs b/src/Microsoft.VisualStudio.Services.Agent/TaskServer.cs index 0244e51115..515ae98d28 100644 --- a/src/Microsoft.VisualStudio.Services.Agent/TaskServer.cs +++ b/src/Microsoft.VisualStudio.Services.Agent/TaskServer.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.TeamFoundation.DistributedTask.WebApi; +using System.Collections.Generic; namespace Microsoft.VisualStudio.Services.Agent { @@ -17,10 +18,11 @@ public interface ITaskServer: IAgentService public sealed class TaskServer : AgentService, ITaskServer { - public Task CreateAgentSessionAsync(Int32 poolId, TaskAgentSession session, CancellationToken cancellationToken) + public async Task CreateAgentSessionAsync(Int32 poolId, TaskAgentSession session, CancellationToken cancellationToken) { // TODO: Pass through to the REST SDK. - throw new System.NotImplementedException(); + //throw new System.NotImplementedException(); + return session; } public Task DeleteAgentMessageAsync(Int32 poolId, Int64 messageId, Guid sessionId, CancellationToken cancellationToken) @@ -33,9 +35,19 @@ public Task DeleteAgentSessionAsync(Int32 poolId, Guid sessionId, CancellationTo throw new System.NotImplementedException(); } - public Task GetAgentMessageAsync(Int32 poolId, Guid sessionId, Int64? lastMessageId, CancellationToken cancellationToken) - { - throw new System.NotImplementedException(); + public async Task GetAgentMessageAsync(Int32 poolId, Guid sessionId, Int64? lastMessageId, CancellationToken cancellationToken) + { + var result = new TaskAgentMessage(); + TaskOrchestrationPlanReference plan = new TaskOrchestrationPlanReference(); + TimelineReference timeline = null; + JobEnvironment environment = new JobEnvironment(); + List tasks = new List(); + Guid JobId = Guid.NewGuid(); + var jobRequest = new JobRequestMessage(plan, timeline, JobId, "someJob", environment, tasks); + result.Body = JsonUtility.ToString(jobRequest); + result.MessageType = JobRequestMessage.MessageType; + result.MessageId = 123; + return result; } } } \ No newline at end of file diff --git a/src/Microsoft.VisualStudio.Services.Agent/Util/AssemblyDirectory.cs b/src/Microsoft.VisualStudio.Services.Agent/Util/AssemblyDirectory.cs new file mode 100644 index 0000000000..27f534435e --- /dev/null +++ b/src/Microsoft.VisualStudio.Services.Agent/Util/AssemblyDirectory.cs @@ -0,0 +1,16 @@ +using System.IO; +using System.Reflection; + +namespace Microsoft.VisualStudio.Services.Agent.Util +{ + public static class AssemblyUtil + { + public static string AssemblyDirectory + { + get + { + return Path.GetDirectoryName(typeof(AssemblyUtil).GetTypeInfo().Assembly.Location); + } + } + } +} diff --git a/src/Microsoft.VisualStudio.Services.Agent/_project.json b/src/Microsoft.VisualStudio.Services.Agent/_project.json index 73338591fe..1763279df4 100644 --- a/src/Microsoft.VisualStudio.Services.Agent/_project.json +++ b/src/Microsoft.VisualStudio.Services.Agent/_project.json @@ -35,6 +35,7 @@ "System.Diagnostics.TraceSource": "4.0.0-rc3-23721", "System.Diagnostics.TextWriterTraceListener": "4.0.0-rc3-23721", "System.IO": "4.1.0-rc3-23721", + "System.IO.Pipes": "4.0.0-rc3-23721", "System.Runtime": "4.1.0-rc3-23721", "System.Xml.XmlSerializer": "4.0.11-rc3-23721", "NETStandard.Library": "1.0.0-rc3-23721" diff --git a/src/Test/L0/MessageListenerL0.cs b/src/Test/L0/MessageListenerL0.cs index 885f6ddeeb..b2fd8e6779 100644 --- a/src/Test/L0/MessageListenerL0.cs +++ b/src/Test/L0/MessageListenerL0.cs @@ -17,7 +17,7 @@ public sealed class MessageListenerL0 { private AgentSettings _settings; private Mock _config; - private Mock _dispatcher; + private Mock _workerManager; private Mock _taskServer; public MessageListenerL0() @@ -25,7 +25,7 @@ public MessageListenerL0() _settings = new AgentSettings { AgentId=1, AgentName="myagent", PoolId=123, PoolName="default", ServerUrl="http://myserver", WorkFolder="_work" }; _config = new Mock(); _config.Setup(x => x.GetSettings()).Returns(_settings); - _dispatcher = new Mock(); + _workerManager = new Mock(); _taskServer = new Mock(); } @@ -33,7 +33,7 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " { TestHostContext tc = new TestHostContext(nameof(MessageListenerL0), testName); tc.RegisterService(_config.Object); - tc.RegisterService(_dispatcher.Object); + tc.RegisterService(_workerManager.Object); tc.RegisterService(_taskServer.Object); return tc; } diff --git a/src/Test/L0/ProcessChannelL0.cs b/src/Test/L0/ProcessChannelL0.cs new file mode 100644 index 0000000000..4aaf0ef836 --- /dev/null +++ b/src/Test/L0/ProcessChannelL0.cs @@ -0,0 +1,90 @@ +using System; +using Xunit; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.TeamFoundation.DistributedTask.WebApi; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.VisualStudio.Services.Agent.Tests +{ + public sealed class ProcessChannelL0 + { + //RunAsync is an "echo" type service which reads + //one message and sends back to the server same data + public static async Task RunAsync(string[] args) + { + using (var client = new ProcessChannel()) + { + SemaphoreSlim signal = new SemaphoreSlim(0, 1); + Func echoFunc = async (message, ct) => + { + var cs2 = new CancellationTokenSource(); + await client.SendAsync(message, cs2.Token); + signal.Release(); + }; + client.JobRequestMessageReceived += echoFunc; + client.StartClient(args[1], args[2]); + // Wait server calls us once and we reply back + await signal.WaitAsync(5000); + client.JobRequestMessageReceived -= echoFunc; + await client.Stop(); + } + } + + //RunIPCEndToEnd test starts another process (the RunAsync function above), + //sends one packet and receives one packet using ProcessChannel class, + //and finally verifies if the data we sent is identical to what we have received + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public async Task RunIPCEndToEnd() + { + using (var server = new ProcessChannel()) + { + SemaphoreSlim signal = new SemaphoreSlim(0, 1); + JobRequestMessage result = null; + TaskOrchestrationPlanReference plan = new TaskOrchestrationPlanReference(); + TimelineReference timeline = null; + JobEnvironment environment = new JobEnvironment(); + List tasks = new List(); + Guid JobId = Guid.NewGuid(); + var jobRequest = new JobRequestMessage(plan, timeline, JobId, "someJob", environment, tasks); + Func verifyFunc = (message, ct) => + { + result = message; + signal.Release(); + return Task.CompletedTask; + }; + server.JobRequestMessageReceived += verifyFunc; + Process jobProcess; + server.StartServer((p1, p2) => + { + string clientFileName = "Test"; +#if OS_WINDOWS + clientFileName += ".exe"; +#endif + jobProcess = new Process(); + jobProcess.StartInfo.FileName = clientFileName; + jobProcess.StartInfo.Arguments = "spawnclient " + p1 + " " + p2; + jobProcess.EnableRaisingEvents = true; + jobProcess.Start(); + }); + var cs = new CancellationTokenSource(); + await server.SendAsync(jobRequest, cs.Token); + + bool timedOut = !await signal.WaitAsync(5000); + // Wait until response is received + if (timedOut) + { + Assert.True(false, "Test timed out."); + } + else { + Assert.True(jobRequest.JobId.Equals(result.JobId) && jobRequest.JobName.Equals(result.JobName)); + } + server.JobRequestMessageReceived -= verifyFunc; + await server.Stop(); + } + } + } +} diff --git a/src/Test/L0/ProcessInvokerL0.cs b/src/Test/L0/ProcessInvokerL0.cs index fd5f395ab8..e9bb888d24 100644 --- a/src/Test/L0/ProcessInvokerL0.cs +++ b/src/Test/L0/ProcessInvokerL0.cs @@ -1,7 +1,7 @@ using System; using System.Diagnostics; -using Microsoft.VisualStudio.Services.Agent; using Xunit; +using System.Threading.Tasks; namespace Microsoft.VisualStudio.Services.Agent.Tests { @@ -10,20 +10,24 @@ public class ProcessInvokerL0 [Fact] [Trait("Level", "L0")] [Trait("Category", "Common")] - public void SuccessExitsWithCodeZero() + public async Task SuccessExitsWithCodeZero() { using (TestHostContext thc = new TestHostContext(nameof(ProcessInvokerL0))) { TraceSource trace = thc.GetTrace(); Int32 exitCode = -1; - #if OS_WINDOWS - exitCode = ProcessInvoker.RunExe(thc, "cmd.exe", "/c \"dir >nul\""); - #endif + var processInvoker = new ProcessInvoker(); + processInvoker.Initialize(thc); +#if OS_WINDOWS + processInvoker.Execute("", "cmd.exe", "/c \"dir >nul\"", null); + exitCode = await processInvoker.WaitForExit(); +#endif - #if (OS_OSX || OS_LINUX) - exitCode = ProcessInvoker.RunExe(thc, "bash", "-c ls > /dev/null"); - #endif +#if (OS_OSX || OS_LINUX) + processInvoker.Execute("", "bash", "-c ls > /dev/null", null); + exitCode = await processInvoker.WaitForExit(); +#endif trace.Info("Exit Code: {0}", exitCode); Assert.Equal(0, exitCode); diff --git a/src/Test/Program.cs b/src/Test/Program.cs new file mode 100644 index 0000000000..53f3168bab --- /dev/null +++ b/src/Test/Program.cs @@ -0,0 +1,15 @@ +namespace Microsoft.VisualStudio.Services.Agent.Tests +{ + public static class Program + { + //this is a special entry point used (for now) only by RunIPCEndToEnd test, + //which launches a second process to verify IPC pipes with an end-to-end test + public static void Main(string[] args) + { + if (null != args && 3 == args.Length && "spawnclient".Equals(args[0].ToLower())) + { + ProcessChannelL0.RunAsync(args).Wait(); + } + } + } +} diff --git a/src/Test/_project.json b/src/Test/_project.json index bb8287cc2e..879562cf10 100644 --- a/src/Test/_project.json +++ b/src/Test/_project.json @@ -1,7 +1,7 @@ { "version": "1.0.0-*", "compilationOptions": { - "emitEntryPoint": false, + "emitEntryPoint": true, "define": [ "SAMPLE_DEFINE" ] },