Skip to content

Commit f1e7275

Browse files
authored
Suppress execution context flow when starting host (#11498)
* Suppress execution context flow when starting host * Update release_notes.md * Fix thread switch on AsyncFlowControl disposal * Add Task.Yield * Fix unit tests
1 parent 69c7dda commit f1e7275

File tree

4 files changed

+115
-61
lines changed

4 files changed

+115
-61
lines changed

release_notes.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
- Add JitTrace Files for v4.1045
88
- Throw exception instead of timing out when worker channel exits before initializing gRPC (#10937)
99
- Adding empty remote message check in the SystemLogger (#11473)
10-
- Fix `webPubSubTrigger`'s for Flex consumption sku (#11489)
10+
- Fix `webPubSubTrigger`'s for Flex consumption sku (#11489)
11+
- Suppress execution context flow into script host start (#11498)
12+
- Add AzureWebJobsStorage health check (#11471)

src/WebJobs.Script.WebHost/WebJobsScriptHostService.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,27 @@ private async Task StartHostAsync(CancellationToken cancellationToken, int attem
326326
/// before calling this method. Host starts and restarts must be synchronous to prevent orphaned
327327
/// hosts or an incorrect ActiveHost.
328328
/// </summary>
329-
private async Task UnsynchronizedStartHostAsync(ScriptHostStartupOperation activeOperation, int attemptCount = 0, JobHostStartupMode startupMode = JobHostStartupMode.Normal)
329+
private Task UnsynchronizedStartHostAsync(ScriptHostStartupOperation activeOperation, int attemptCount = 0, JobHostStartupMode startupMode = JobHostStartupMode.Normal)
330330
{
331+
// We may be started from a variety of contexts, some of which may carry AsyncLocal state (such as Activity). We do not want any of this
332+
// to flow into the host startup logic, so we suppress the flow of the ExecutionContext.
333+
Task start;
334+
using (System.Threading.ExecutionContext.SuppressFlow())
335+
{
336+
start = UnsynchronizedStartHostCoreAsync(activeOperation, attemptCount, startupMode);
337+
}
338+
339+
return start;
340+
}
341+
342+
/// <summary>
343+
/// Starts the host without taking a lock. Callers must take a lock on _hostStartSemaphore
344+
/// before calling this method. Host starts and restarts must be synchronous to prevent orphaned
345+
/// hosts or an incorrect ActiveHost.
346+
/// </summary>
347+
private async Task UnsynchronizedStartHostCoreAsync(ScriptHostStartupOperation activeOperation, int attemptCount, JobHostStartupMode startupMode)
348+
{
349+
await Task.Yield(); // ensure any async context is properly suppressed.
331350
await CheckFileSystemAsync();
332351
if (ShutdownRequested)
333352
{
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Diagnostics;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.WebJobs.Script.Tests
9+
{
10+
public static class TaskExtensions
11+
{
12+
/// <summary>
13+
/// Waits for a task to complete with a test-appropriate timeout. If a debugger is attached, waits indefinitely.
14+
/// </summary>
15+
/// <param name="task">The task to wait on.</param>
16+
/// <returns>A <see cref="Task"/> representing the asynchronous wait.</returns>
17+
public static Task TestWaitAsync(this Task task)
18+
{
19+
return task.WaitAsync(TimeSpan.FromSeconds(10));
20+
}
21+
22+
/// <summary>
23+
/// Waits for a task to complete with a test-appropriate timeout. If a debugger is attached, waits indefinitely.
24+
/// </summary>
25+
/// <param name="task">The task to wait on.</param>
26+
/// <param name="timeout">The timeout to use if no debugger is attached.</param>
27+
/// <returns>A <see cref="Task"/> representing the asynchronous wait.</returns>
28+
public static Task TestWaitAsync(this Task task, TimeSpan timeout)
29+
{
30+
ArgumentNullException.ThrowIfNull(task);
31+
if (Debugger.IsAttached)
32+
{
33+
return task;
34+
}
35+
36+
return task.WaitAsync(timeout);
37+
}
38+
}
39+
}

test/WebJobs.Script.Tests/WebJobsScriptHostServiceTests.cs

Lines changed: 53 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ private Mock<IHost> CreateMockHost(SemaphoreSlim disposedSemaphore = null)
105105
public async Task StartAsync_Succeeds()
106106
{
107107
var hostBuilder = new Mock<IScriptHostBuilder>();
108+
_host.Setup(h => h.StartAsync(It.IsAny<CancellationToken>()))
109+
.Callback(() =>
110+
{
111+
if (Activity.Current != null)
112+
{
113+
throw new InvalidOperationException("Activity flowed into host start.");
114+
}
115+
})
116+
.Returns(Task.CompletedTask);
108117
hostBuilder.Setup(b => b.BuildHost(It.IsAny<bool>(), It.IsAny<bool>())).Returns(_host.Object);
109118

110119
_webHostLoggerProvider = new TestLoggerProvider();
@@ -124,6 +133,8 @@ public async Task StartAsync_Succeeds()
124133
_hostBuiltChangeTokenSource,
125134
_hostBuiltChangeTokenSourceResolverOptions);
126135

136+
using Activity activity = new("TestActivity_ShouldNotFlowIntoHostStart");
137+
activity.Start();
127138
await _hostService.StartAsync(CancellationToken.None);
128139

129140
// add general post startup validations here
@@ -190,12 +201,9 @@ public async Task HostRestart_Specialization_Succeeds()
190201
await _hostService.StartAsync(CancellationToken.None);
191202
Assert.True(AreRequiredMetricsGenerated(metricsLogger));
192203

193-
Thread restartHostThread = new Thread(new ThreadStart(RestartHost));
194-
Thread specializeHostThread = new Thread(new ThreadStart(SpecializeHost));
195-
restartHostThread.Start();
196-
specializeHostThread.Start();
197-
restartHostThread.Join();
198-
specializeHostThread.Join();
204+
Task restartHost = _hostService.RestartHostAsync("test", default);
205+
Task specializeHost = SpecializeHostAsync();
206+
await Task.WhenAll(restartHost, specializeHost).TestWaitAsync(TimeSpan.FromSeconds(60)); // this is a slow test for some reason.
199207

200208
var logMessages = _webHostLoggerProvider.GetAllLogMessages().Where(m => m.FormattedMessage.Contains("Restarting host."));
201209
Assert.Equal(2, logMessages.Count());
@@ -204,9 +212,8 @@ public async Task HostRestart_Specialization_Succeeds()
204212
[Fact]
205213
public async Task HostRestart_DuringInitializationWithError_Recovers()
206214
{
207-
SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
208-
await semaphore.WaitAsync();
209-
bool waitingInHostBuilder = false;
215+
TaskCompletionSource startBlock = new();
216+
TaskCompletionSource initialStart = new();
210217
var metricsLogger = new TestMetricsLogger();
211218

212219
// Have the first host start, but pause. We'll issue a restart, then let
@@ -216,8 +223,8 @@ public async Task HostRestart_DuringInitializationWithError_Recovers()
216223
.Setup(h => h.StartAsync(It.IsAny<CancellationToken>()))
217224
.Returns(async () =>
218225
{
219-
waitingInHostBuilder = true;
220-
await semaphore.WaitAsync();
226+
initialStart.SetResult();
227+
await startBlock.Task;
221228
throw new InvalidOperationException("Something happened at startup!");
222229
});
223230

@@ -226,17 +233,14 @@ public async Task HostRestart_DuringInitializationWithError_Recovers()
226233
.Setup(h => h.StartAsync(It.IsAny<CancellationToken>()))
227234
.Returns(() => Task.CompletedTask);
228235

229-
var hostBuilder = new Mock<IScriptHostBuilder>();
230-
hostBuilder.SetupSequence(b => b.BuildHost(It.IsAny<bool>(), It.IsAny<bool>()))
231-
.Returns(hostA.Object)
232-
.Returns(hostB.Object);
236+
OrderedScriptHostBuilder hostBuilder = new(hostA.Object, hostB.Object);
233237

234238
_webHostLoggerProvider = new TestLoggerProvider();
235239
_loggerFactory = new LoggerFactory();
236240
_loggerFactory.AddProvider(_webHostLoggerProvider);
237241

238242
_hostService = new WebJobsScriptHostService(
239-
_monitor, hostBuilder.Object, _loggerFactory,
243+
_monitor, hostBuilder, _loggerFactory,
240244
_mockScriptWebHostEnvironment.Object, _mockEnvironment.Object,
241245
_hostPerformanceManager, _healthMonitorOptions,
242246
metricsLogger, new Mock<IApplicationLifetime>().Object,
@@ -245,20 +249,17 @@ public async Task HostRestart_DuringInitializationWithError_Recovers()
245249
TestLoggerProvider hostALogger = hostA.Object.GetTestLoggerProvider();
246250
TestLoggerProvider hostBLogger = hostB.Object.GetTestLoggerProvider();
247251

248-
Task initialStart = _hostService.StartAsync(CancellationToken.None);
249-
250-
Thread restartHostThread = new Thread(new ThreadStart(RestartHost));
251-
restartHostThread.Start();
252-
await TestHelpers.Await(() => waitingInHostBuilder);
252+
Task firstStart = _hostService.StartAsync(CancellationToken.None);
253+
await initialStart.Task.TestWaitAsync();
254+
Task restartTask = _hostService.RestartHostAsync("test", default);
253255

254256
// Now let the first host continue. It will throw, which should be correctly handled.
255-
semaphore.Release();
257+
startBlock.SetResult();
256258

257-
await TestHelpers.Await(() => _hostService.State == ScriptHostState.Running);
258-
259-
restartHostThread.Join();
259+
await restartTask;
260+
Assert.Equal(ScriptHostState.Running, _hostService.State);
260261

261-
await initialStart;
262+
await firstStart;
262263
Assert.True(AreRequiredMetricsGenerated(metricsLogger));
263264

264265
// Make sure the error was logged to the correct logger
@@ -280,61 +281,48 @@ public async Task HostRestart_DuringInitializationWithError_Recovers()
280281
[Fact]
281282
public async Task HostRestart_DuringInitialization_Cancels()
282283
{
283-
SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
284-
await semaphore.WaitAsync();
285-
bool waitingInHostBuilder = false;
284+
TaskCompletionSource startBlock = new();
285+
TaskCompletionSource initialStart = new();
286286
var metricsLogger = new TestMetricsLogger();
287287

288288
// Have the first host start, but pause. We'll issue a restart, then
289-
// let this first host continue and see the cancelation token has fired.
289+
// let this first host continue and see the cancellation token has fired.
290290
var hostA = CreateMockHost();
291291
hostA
292292
.Setup(h => h.StartAsync(It.IsAny<CancellationToken>()))
293293
.Returns<CancellationToken>(async ct =>
294294
{
295-
waitingInHostBuilder = true;
296-
await semaphore.WaitAsync();
297-
ct.ThrowIfCancellationRequested();
295+
initialStart.SetResult();
296+
await startBlock.Task.WaitAsync(ct);
298297
});
299298

300299
var hostB = CreateMockHost();
301300
hostB
302301
.Setup(h => h.StartAsync(It.IsAny<CancellationToken>()))
303302
.Returns(() => Task.CompletedTask);
304303

305-
var hostBuilder = new Mock<IScriptHostBuilder>();
306-
hostBuilder.SetupSequence(b => b.BuildHost(It.IsAny<bool>(), It.IsAny<bool>()))
307-
.Returns(hostA.Object)
308-
.Returns(hostB.Object);
304+
OrderedScriptHostBuilder hostBuilder = new(hostA.Object, hostB.Object);
309305

310306
_webHostLoggerProvider = new TestLoggerProvider();
311307
_loggerFactory = new LoggerFactory();
312308
_loggerFactory.AddProvider(_webHostLoggerProvider);
313309

314310
_hostService = new WebJobsScriptHostService(
315-
_monitor, hostBuilder.Object, _loggerFactory,
311+
_monitor, hostBuilder, _loggerFactory,
316312
_mockScriptWebHostEnvironment.Object, _mockEnvironment.Object,
317313
_hostPerformanceManager, _healthMonitorOptions,
318314
metricsLogger, new Mock<IApplicationLifetime>().Object,
319315
_mockConfig, new TestScriptEventManager(), _hostMetrics, _functionsHostingConfigOptions, _hostBuiltChangeTokenSource, _hostBuiltChangeTokenSourceResolverOptions);
320316

321317
TestLoggerProvider hostALogger = hostA.Object.GetTestLoggerProvider();
318+
Task firstStart = _hostService.StartAsync(default);
319+
await initialStart.Task.TestWaitAsync();
320+
Task restartTask = _hostService.RestartHostAsync("test", default);
322321

323-
Task initialStart = _hostService.StartAsync(CancellationToken.None);
324-
325-
Thread restartHostThread = new Thread(new ThreadStart(RestartHost));
326-
restartHostThread.Start();
327-
await TestHelpers.Await(() => waitingInHostBuilder);
328-
await TestHelpers.Await(() => _webHostLoggerProvider.GetLog().Contains("Restart requested."));
329-
330-
// Now let the first host continue. It will see that startup is canceled.
331-
semaphore.Release();
332-
333-
await TestHelpers.Await(() => _hostService.State == ScriptHostState.Running);
334-
335-
restartHostThread.Join();
322+
await restartTask;
323+
Assert.Equal(ScriptHostState.Running, _hostService.State);
336324

337-
await initialStart;
325+
await firstStart;
338326
Assert.True(AreRequiredMetricsGenerated(metricsLogger));
339327

340328
// Make sure the error was logged to the correct logger
@@ -582,12 +570,7 @@ public async Task DependencyTrackingTelemetryModule_Race()
582570
await listenerTask;
583571
}
584572

585-
public void RestartHost()
586-
{
587-
_hostService.RestartHostAsync("test", CancellationToken.None).Wait();
588-
}
589-
590-
public void SpecializeHost()
573+
public Task SpecializeHostAsync()
591574
{
592575
var mockScriptWebHostEnvironment = new Mock<IScriptWebHostEnvironment>();
593576
Mock<IConfigurationRoot> mockConfiguration = new Mock<IConfigurationRoot>();
@@ -600,7 +583,7 @@ public void SpecializeHost()
600583
var hostNameProvider = new HostNameProvider(mockEnvironment.Object);
601584
var mockApplicationLifetime = new Mock<IApplicationLifetime>(MockBehavior.Strict);
602585
var manager = new StandbyManager(_hostService, mockLanguageWorkerChannelManager.Object, mockConfiguration.Object, mockScriptWebHostEnvironment.Object, mockEnvironment.Object, _monitor, testLogger, hostNameProvider, mockApplicationLifetime.Object, new TestMetricsLogger());
603-
manager.SpecializeHostAsync().Wait();
586+
return manager.SpecializeHostAsync();
604587
}
605588

606589
private bool AreRequiredMetricsGenerated(TestMetricsLogger testMetricsLogger)
@@ -646,5 +629,16 @@ public IHost BuildHost(bool skipHostStartup, bool skipHostConfigurationParsing)
646629
return null;
647630
}
648631
}
632+
633+
// Moq SetupSequence doesn't work with execution context suppression.
634+
private class OrderedScriptHostBuilder(params IHost[] hosts) : IScriptHostBuilder
635+
{
636+
private readonly Queue<IHost> _hosts = new(hosts);
637+
638+
public IHost BuildHost(bool skipHostStartup, bool skipHostConfigurationParsing)
639+
{
640+
return _hosts.Dequeue();
641+
}
642+
}
649643
}
650644
}

0 commit comments

Comments
 (0)