Skip to content

Commit fa18357

Browse files
Merge pull request #160 from deanmarcussen/dm/zerolength
Fix Zero Length Images
2 parents 8f67aae + 76a781f commit fa18357

File tree

5 files changed

+214
-79
lines changed

5 files changed

+214
-79
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Threading.Tasks;
7+
8+
namespace SixLabors.ImageSharp.Web.Middleware
9+
{
10+
/// <summary>
11+
/// Extensions used to manage asynchronous access to the <see cref="ImageSharpMiddleware"/>
12+
/// https://gist.github.com/davidfowl/3dac8f7b3d141ae87abf770d5781feed
13+
/// </summary>
14+
public static class ConcurrentDictionaryExtensions
15+
{
16+
/// <summary>
17+
/// Provides an alternative to <see cref="ConcurrentDictionary{TKey, TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/> specifically for asynchronous values. The factory method will only run once.
18+
/// </summary>
19+
/// <typeparam name="TKey">The type of the key.</typeparam>
20+
/// <typeparam name="TValue">The value for the dictionary.</typeparam>
21+
/// <param name="dictionary">The <see cref="ConcurrentDictionary{TKey, TValue}"/>.</param>
22+
/// <param name="key">The key of the element to add.</param>
23+
/// <param name="valueFactory">The function used to generate a value for the key</param>
24+
/// <returns>The value for the key. This will be either the existing value for the key if the
25+
/// key is already in the dictionary, or the new value for the key as returned by valueFactory
26+
/// if the key was not in the dictionary.</returns>
27+
public static async Task<TValue> GetOrAddAsync<TKey, TValue>(
28+
this ConcurrentDictionary<TKey, Task<TValue>> dictionary,
29+
TKey key,
30+
Func<TKey, Task<TValue>> valueFactory)
31+
{
32+
while (true)
33+
{
34+
if (dictionary.TryGetValue(key, out var task))
35+
{
36+
return await task;
37+
}
38+
39+
// This is the task that we'll return to all waiters. We'll complete it when the factory is complete
40+
var tcs = new TaskCompletionSource<TValue>(TaskCreationOptions.RunContinuationsAsynchronously);
41+
if (dictionary.TryAdd(key, tcs.Task))
42+
{
43+
try
44+
{
45+
var value = await valueFactory(key);
46+
tcs.TrySetResult(value);
47+
return await tcs.Task;
48+
}
49+
catch (Exception ex)
50+
{
51+
// Make sure all waiters see the exception
52+
tcs.SetException(ex);
53+
54+
// We remove the entry if the factory failed so it's not a permanent failure
55+
// and future gets can retry (this could be a pluggable policy)
56+
dictionary.TryRemove(key, out _);
57+
throw;
58+
}
59+
}
60+
}
61+
}
62+
}
63+
}

src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs

Lines changed: 104 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ public class ImageSharpMiddleware
3232
/// <summary>
3333
/// The write worker used for limiting identical requests.
3434
/// </summary>
35-
private static readonly ConcurrentDictionary<string, Lazy<Task>> WriteWorkers
36-
= new ConcurrentDictionary<string, Lazy<Task>>(StringComparer.OrdinalIgnoreCase);
35+
private static readonly ConcurrentDictionary<string, Task<ImageWorkerResult>> WriteWorkers
36+
= new ConcurrentDictionary<string, Task<ImageWorkerResult>>(StringComparer.OrdinalIgnoreCase);
3737

3838
/// <summary>
3939
/// The read worker used for limiting identical requests.
4040
/// </summary>
41-
private static readonly ConcurrentDictionary<string, Lazy<Task<ValueTuple<bool, ImageMetadata>>>> ReadWorkers
42-
= new ConcurrentDictionary<string, Lazy<Task<ValueTuple<bool, ImageMetadata>>>>(StringComparer.OrdinalIgnoreCase);
41+
private static readonly ConcurrentDictionary<string, Task<ImageWorkerResult>> ReadWorkers
42+
= new ConcurrentDictionary<string, Task<ImageWorkerResult>>(StringComparer.OrdinalIgnoreCase);
4343

4444
/// <summary>
4545
/// Used to temporarily store source metadata reads to reduce the overhead of cache lookups.
@@ -251,30 +251,40 @@ private async Task ProcessRequestAsync(
251251

252252
// Check the cache, if present, not out of date and not requiring and update
253253
// we'll simply serve the file from there.
254-
(bool newOrUpdated, ImageMetadata sourceImageMetadata) =
255-
await this.IsNewOrUpdatedAsync(sourceImageResolver, imageContext, key);
254+
ImageWorkerResult readResult = default;
255+
try
256+
{
257+
readResult = await this.IsNewOrUpdatedAsync(sourceImageResolver, imageContext, key);
258+
}
259+
finally
260+
{
261+
ReadWorkers.TryRemove(key, out Task<ImageWorkerResult> _);
262+
}
256263

257-
if (!newOrUpdated)
264+
if (!readResult.IsNewOrUpdated)
258265
{
266+
await this.SendResponseAsync(imageContext, key, readResult.CacheImageMetadata, readResult.Resolver);
259267
return;
260268
}
261269

262-
// Not cached? Let's get it from the image resolver.
263-
RecyclableMemoryStream outStream = null;
270+
// Not cached, or is updated? Let's get it from the image resolver.
271+
var sourceImageMetadata = readResult.SourceImageMetadata;
264272

265-
// Enter a write lock which locks writing and any reads for the same request.
266-
// This reduces the overheads of unnecessary processing plus avoids file locks.
267-
await WriteWorkers.GetOrAdd(
268-
key,
269-
_ => new Lazy<Task>(
270-
async () =>
273+
// Enter an asynchronous write worker which prevents multiple writes and delays any reads for the same request.
274+
// This reduces the overheads of unnecessary processing.
275+
try
276+
{
277+
ImageWorkerResult writeResult = await WriteWorkers.GetOrAddAsync(
278+
key,
279+
async (key) =>
271280
{
281+
RecyclableMemoryStream outStream = null;
272282
try
273283
{
274284
// Prevent a second request from starting a read during write execution.
275-
if (ReadWorkers.TryGetValue(key, out Lazy<Task<(bool, ImageMetadata)>> readWork))
285+
if (ReadWorkers.TryGetValue(key, out Task<ImageWorkerResult> readWork))
276286
{
277-
await readWork.Value;
287+
await readWork;
278288
}
279289

280290
ImageCacheMetadata cachedImageMetadata = default;
@@ -334,11 +344,27 @@ await WriteWorkers.GetOrAdd(
334344
// Save the image to the cache and send the response to the caller.
335345
await this.cache.SetAsync(key, outStream, cachedImageMetadata);
336346

337-
// Remove the resolver from the cache so we always resolve next request
347+
// Remove any resolver from the cache so we always resolve next request
338348
// for the same key.
339349
CacheResolverLru.TryRemove(key);
340350

341-
await this.SendResponseAsync(imageContext, key, cachedImageMetadata, outStream, null);
351+
// Place the resolver in the lru cache.
352+
(IImageCacheResolver ImageCacheResolver, ImageCacheMetadata ImageCacheMetadata) cachedImage = await
353+
CacheResolverLru.GetOrAddAsync(
354+
key,
355+
async k =>
356+
{
357+
IImageCacheResolver resolver = await this.cache.GetAsync(k);
358+
ImageCacheMetadata metadata = default;
359+
if (resolver != null)
360+
{
361+
metadata = await resolver.GetMetaDataAsync();
362+
}
363+
364+
return (resolver, metadata);
365+
});
366+
367+
return new ImageWorkerResult(cachedImage.ImageCacheMetadata, cachedImage.ImageCacheResolver);
342368
}
343369
catch (Exception ex)
344370
{
@@ -350,9 +376,17 @@ await WriteWorkers.GetOrAdd(
350376
finally
351377
{
352378
await this.StreamDisposeAsync(outStream);
353-
WriteWorkers.TryRemove(key, out Lazy<Task> _);
354379
}
355-
}, LazyThreadSafetyMode.ExecutionAndPublication)).Value;
380+
});
381+
382+
await this.SendResponseAsync(imageContext, key, writeResult.CacheImageMetadata, writeResult.Resolver);
383+
}
384+
finally
385+
{
386+
// As soon as we have sent a response from a writer the result is available from a reader so we remove this task.
387+
// Any existing awaiters will continue to await.
388+
WriteWorkers.TryRemove(key, out Task<ImageWorkerResult> _);
389+
}
356390
}
357391

358392
private ValueTask StreamDisposeAsync(Stream stream)
@@ -377,85 +411,72 @@ private ValueTask StreamDisposeAsync(Stream stream)
377411
#endif
378412
}
379413

380-
private async Task<ValueTuple<bool, ImageMetadata>> IsNewOrUpdatedAsync(
414+
private async Task<ImageWorkerResult> IsNewOrUpdatedAsync(
381415
IImageResolver sourceImageResolver,
382416
ImageContext imageContext,
383417
string key)
384418
{
385-
if (WriteWorkers.TryGetValue(key, out Lazy<Task> writeWork))
419+
// Pause until the write has been completed.
420+
if (WriteWorkers.TryGetValue(key, out Task<ImageWorkerResult> writeWorkResult))
386421
{
387-
await writeWork.Value;
422+
return await writeWorkResult;
388423
}
389424

390-
if (ReadWorkers.TryGetValue(key, out Lazy<Task<(bool, ImageMetadata)>> readWork))
391-
{
392-
return await readWork.Value;
393-
}
394-
395-
return await ReadWorkers.GetOrAdd(
425+
return await ReadWorkers.GetOrAddAsync(
396426
key,
397-
_ => new Lazy<Task<ValueTuple<bool, ImageMetadata>>>(
398-
async () =>
427+
async (key) =>
399428
{
400-
try
401-
{
402-
// Get the source metadata for processing, storing the result for future checks.
403-
ImageMetadata sourceImageMetadata = await
404-
SourceMetadataLru.GetOrAddAsync(
405-
key,
406-
_ => sourceImageResolver.GetMetaDataAsync());
407-
408-
// Check to see if the cache contains this image.
409-
// If not, we return early. No further checks necessary.
410-
(IImageCacheResolver ImageCacheResolver, ImageCacheMetadata ImageCacheMetadata) cachedImage = await
411-
CacheResolverLru.GetOrAddAsync(
412-
key,
413-
async k =>
429+
// Get the source metadata for processing, storing the result for future checks.
430+
ImageMetadata sourceImageMetadata = await
431+
SourceMetadataLru.GetOrAddAsync(
432+
key,
433+
_ => sourceImageResolver.GetMetaDataAsync());
434+
435+
// Check to see if the cache contains this image.
436+
// If not, we return early. No further checks necessary.
437+
(IImageCacheResolver ImageCacheResolver, ImageCacheMetadata ImageCacheMetadata) cachedImage = await
438+
CacheResolverLru.GetOrAddAsync(
439+
key,
440+
async k =>
441+
{
442+
IImageCacheResolver resolver = await this.cache.GetAsync(k);
443+
ImageCacheMetadata metadata = default;
444+
if (resolver != null)
414445
{
415-
IImageCacheResolver resolver = await this.cache.GetAsync(k);
416-
ImageCacheMetadata metadata = default;
417-
if (resolver != null)
418-
{
419-
metadata = await resolver.GetMetaDataAsync();
420-
}
421-
422-
return (resolver, metadata);
423-
});
446+
metadata = await resolver.GetMetaDataAsync();
447+
}
424448

425-
if (cachedImage.ImageCacheResolver is null)
426-
{
427-
// Remove the null resolver from the store.
428-
CacheResolverLru.TryRemove(key);
429-
return (true, sourceImageMetadata);
430-
}
449+
return (resolver, metadata);
450+
});
431451

432-
// Has the cached image expired?
433-
// Or has the source image changed since the image was last cached?
434-
if (cachedImage.ImageCacheMetadata.ContentLength == 0 // Fix for old cache without length property
435-
|| cachedImage.ImageCacheMetadata.CacheLastWriteTimeUtc <= (DateTimeOffset.UtcNow - this.options.CacheMaxAge)
436-
|| cachedImage.ImageCacheMetadata.SourceLastWriteTimeUtc != sourceImageMetadata.LastWriteTimeUtc)
437-
{
438-
// We want to remove the resolver from the store so that the next check gets the updated file.
439-
CacheResolverLru.TryRemove(key);
440-
return (true, sourceImageMetadata);
441-
}
452+
if (cachedImage.ImageCacheResolver is null)
453+
{
454+
// Remove the null resolver from the store.
455+
CacheResolverLru.TryRemove(key);
442456

443-
// We're pulling the image from the cache.
444-
await this.SendResponseAsync(imageContext, key, cachedImage.ImageCacheMetadata, null, cachedImage.ImageCacheResolver);
445-
return (false, sourceImageMetadata);
457+
return new ImageWorkerResult(sourceImageMetadata);
446458
}
447-
finally
459+
460+
// Has the cached image expired?
461+
// Or has the source image changed since the image was last cached?
462+
if (cachedImage.ImageCacheMetadata.ContentLength == 0 // Fix for old cache without length property
463+
|| cachedImage.ImageCacheMetadata.CacheLastWriteTimeUtc <= (DateTimeOffset.UtcNow - this.options.CacheMaxAge)
464+
|| cachedImage.ImageCacheMetadata.SourceLastWriteTimeUtc != sourceImageMetadata.LastWriteTimeUtc)
448465
{
449-
ReadWorkers.TryRemove(key, out Lazy<Task<(bool, ImageMetadata)>> _);
466+
// We want to remove the resolver from the store so that the next check gets the updated file.
467+
CacheResolverLru.TryRemove(key);
468+
return new ImageWorkerResult(sourceImageMetadata);
450469
}
451-
}, LazyThreadSafetyMode.ExecutionAndPublication)).Value;
470+
471+
// The image is cached. Return the cached image so multiple callers can write a response.
472+
return new ImageWorkerResult(sourceImageMetadata, cachedImage.ImageCacheMetadata, cachedImage.ImageCacheResolver);
473+
});
452474
}
453475

454476
private async Task SendResponseAsync(
455477
ImageContext imageContext,
456478
string key,
457479
ImageCacheMetadata metadata,
458-
Stream stream,
459480
IImageCacheResolver cacheResolver)
460481
{
461482
imageContext.ComprehendRequestHeaders(metadata.CacheLastWriteTimeUtc, metadata.ContentLength);
@@ -473,7 +494,11 @@ private async Task SendResponseAsync(
473494
this.logger.LogImageServed(imageContext.GetDisplayUrl(), key);
474495

475496
// When stream is null we're sending from the cache.
476-
await imageContext.SendAsync(stream ?? await cacheResolver.OpenReadAsync(), metadata);
497+
using (var stream = await cacheResolver.OpenReadAsync())
498+
{
499+
await imageContext.SendAsync(stream, metadata);
500+
}
501+
477502
return;
478503

479504
case ImageContext.PreconditionState.NotModified:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
using SixLabors.ImageSharp.Web.Resolvers;
5+
6+
namespace SixLabors.ImageSharp.Web.Middleware
7+
{
8+
/// <summary>
9+
/// Provides an asynchronous worker result.
10+
/// </summary>
11+
internal readonly struct ImageWorkerResult
12+
{
13+
public ImageWorkerResult(ImageMetadata sourceImageMetadata)
14+
{
15+
this.IsNewOrUpdated = true;
16+
this.SourceImageMetadata = sourceImageMetadata;
17+
this.CacheImageMetadata = default;
18+
this.Resolver = default;
19+
}
20+
21+
public ImageWorkerResult(ImageMetadata sourceImageMetadata, ImageCacheMetadata cacheImageMetadata, IImageCacheResolver resolver)
22+
{
23+
this.IsNewOrUpdated = false;
24+
this.SourceImageMetadata = sourceImageMetadata;
25+
this.CacheImageMetadata = cacheImageMetadata;
26+
this.Resolver = resolver;
27+
}
28+
29+
public ImageWorkerResult(ImageCacheMetadata cacheImageMetadata, IImageCacheResolver resolver)
30+
{
31+
this.IsNewOrUpdated = false;
32+
this.SourceImageMetadata = default;
33+
this.CacheImageMetadata = cacheImageMetadata;
34+
this.Resolver = resolver;
35+
}
36+
37+
public bool IsNewOrUpdated { get; }
38+
39+
public ImageMetadata SourceImageMetadata { get; }
40+
41+
public ImageCacheMetadata CacheImageMetadata { get; }
42+
43+
public IImageCacheResolver Resolver { get; }
44+
}
45+
}

tests/ImageSharp.Web.Tests/Processing/AzureBlobStorageCacheServerTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ public async Task CanProcessMultipleIdenticalQueriesAsync(string url)
111111
using HttpResponseMessage response = await this.HttpClient.GetAsync(url + command);
112112
Assert.NotNull(response);
113113
Assert.True(response.IsSuccessStatusCode);
114+
Assert.True(response.Content.Headers.ContentLength > 0);
114115
})).ToArray();
115116

116117
var all = Task.WhenAll(tasks);

tests/ImageSharp.Web.Tests/Processing/PhysicalFileSystemCacheServerTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ public async Task CanProcessMultipleIdenticalQueriesAsync(string url)
111111
using HttpResponseMessage response = await this.HttpClient.GetAsync(url + command);
112112
Assert.NotNull(response);
113113
Assert.True(response.IsSuccessStatusCode);
114+
Assert.True(response.Content.Headers.ContentLength > 0);
114115
})).ToArray();
115116

116117
var all = Task.WhenAll(tasks);

0 commit comments

Comments
 (0)