Post

Upgrading the .NET media service - an adventure into Vibe Coding

Implementing proper caching on the media service, and some thoughts on "letting AI do it".

Upgrading the .NET media service - an adventure into Vibe Coding

Back in January I shifted the way media (for the most part, images) are stored for use on this blog away from just being kept in an ever-expanding, source-controlled folder embedded in the application, and onto cloud storage served via a separate web service. There were reasons for that beyond just not wanting app bloat, which I went into in that post but will spare you the exposition here.

What has been nagging away in my mind, though, is the fact I didn’t quite finish the job; media was being re-downloaded from the blob service every time (Sort of. Modern web browsers will help with caching for this sort of thing most of the time), so it would be speedier (and cheaper) to implement a simple media cache where files would be re-downloaded periodically. The files are never going to change, so sync issues aren’t a problem.

I finally got around to dusting off the codebase for the media service this week, and considered that this might be an opportunity to study how the GitHub Copilot coding assistant for VS Code might approach the problem.

I’ve spent 2025 working on-and-off with AI agents for various purposes not limited to code assistants already, so I’m fairly versed in what they can and cannot do (and how to get the best out of them), and I know this is actually quite an easy use case for it, but given I’d documented the previous version of the media server on here - proudly built entirely with the help of my fully organic brain and bony appendages - it might be good to do it again and compare its output with what I may have attempted solo.

Alternatively, just skip to the end to read my thoughts on AI in the software industry as a whole.

Starting off - first pass

After loading in the code to the IDE, I started by throwing at Copilot a prompt that explained what the system did (it could probably have worked this out, but just to be sure), what the problem currently is (to try and guide any potential solution towards solving this problem in particular), and - rather vaguely - what I want it to do:

this is a simple media server i use to serve content from azure blob storage to a web app (separate repo). it works well but can be slow to load the media sometimes in a browser. i want to implement some sort of caching so that media is served more quickly

It got straight to work. Broadly speaking, it decided to build a media-cache folder within the application and use that as the initial source for any media retrievals. Failing that (or if the media file was in the cache but was quite old), download it from the blob service.

In appSettings.json:

1
2
3
4
  "MediaCache": {
    "CacheDirectory": "media-cache",
    "TTLSeconds": 86400
  }

It added a new namespace called Services and created a whole new class called FileBlobCache.cs (I’ve only included the important bits):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
namespace Hornbyjw.Media.Services
{

..

    public class FileBlobCache : IFileBlobCache
    {
        ..
        private readonly string _cacheRoot;
        private readonly TimeSpan _ttl;
        private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

        public FileBlobCache(IConfiguration configuration, ILogger<FileBlobCache> logger)
        {
            ..
            var section = configuration.GetSection("MediaCache");
            _cacheRoot = section.GetValue<string>("CacheDirectory") ?? Path.Combine(Directory.GetCurrentDirectory(), "media-cache");
            var ttlSeconds = section.GetValue<int?>("TTLSeconds") ?? 24 * 60 * 60; // default 1 day
            _ttl = TimeSpan.FromSeconds(ttlSeconds);

            Directory.CreateDirectory(_cacheRoot);
        }

        public async Task<Stream> GetOrDownloadAsync(string assetPath, BlockBlobClient blobClient, CancellationToken cancellationToken = default)
        {
            // Sanitize assetPath into a relative file path under cache root
            var safeRelative = MakeSafeRelativePath(assetPath);
            var finalPath = Path.Combine(_cacheRoot, safeRelative);

            // Ensure directory exists
            var dir = Path.GetDirectoryName(finalPath);
            if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);

            // If cached and fresh, return stream
            if (File.Exists(finalPath))
            {
                var fi = new FileInfo(finalPath);
                var age = DateTime.UtcNow - fi.LastWriteTimeUtc;
                if (age <= _ttl)
                {
                    _logger.LogDebug("Cache hit for {AssetPath}", assetPath);
                    var fs = new FileStream(finalPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
                    return fs;
                }
                else
                {
                    _logger.LogDebug("Cache expired for {AssetPath}", assetPath);
                }
            }

            // Acquire per-key lock to avoid multiple downloads
            var sem = _locks.GetOrAdd(finalPath, _ => new SemaphoreSlim(1, 1));
            await sem.WaitAsync(cancellationToken).ConfigureAwait(false);
            try
            {
                // Double-check after acquiring lock
                if (File.Exists(finalPath))
                {
                    var fi = new FileInfo(finalPath);
                    var age = DateTime.UtcNow - fi.LastWriteTimeUtc;
                    if (age <= _ttl)
                    {
                        _logger.LogDebug("Cache hit (after wait) for {AssetPath}", assetPath);
                        return new FileStream(finalPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
                    }
                }

                // Download to a temp file then move into place
                var tempPath = finalPath + ".download" + Guid.NewGuid().ToString("N");
                try
                {
                    _logger.LogInformation("Downloading blob to cache: {AssetPath} -> {Path}", assetPath, finalPath);
                    using (var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
                    {
                        await blobClient.DownloadToAsync(fs, cancellationToken).ConfigureAwait(false);
                    }

                    // Move into place (overwrite if necessary)
                    if (File.Exists(finalPath)) File.Delete(finalPath);
                    File.Move(tempPath, finalPath);

                    // Return read stream
                    return new FileStream(finalPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
                }
                catch
                {
                    // Remove temp file on failure
                    try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
                    throw;
                }
            }
            finally
            {
                sem.Release();
                // clean up semaphore entry (non-blocking)
                _locks.TryRemove(finalPath, out _);
            }
        }

        ..
    }
}

And finally updated the MediaController to make use of this new cache manager:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        ..
        [ResponseCache(Duration = 2629746, Location = ResponseCacheLocation.Any)]
        public async Task<IActionResult> Get(string assetPath)
        {
            ..
                // Download or serve from cache
                var blobClient = container.GetBlockBlobClient(assetPath);
                if (await blobClient.ExistsAsync())
                {
                    // Use local file cache to speed repeated reads. This returns a seekable FileStream when cached.
                    var stream = await _fileBlobCache.GetOrDownloadAsync(assetPath, blobClient).ConfigureAwait(false);
                    // enable range processing so browsers can seek in media files
                    return File(stream, mimeType, enableRangeProcessing: true);
                }
            ..
        }

There are a few interesting things to look at here. The first is its use of a ConcurrentDictionary and SemaphoreSlim to manage locks due to multithreading. I’ll admit, I hadn’t properly considered race conditions when drawing up a rough plan in my head (I’ll also admit that I hadn’t really thought hard about it at all and was taking a ‘I’ll figure it out as I go’ approach).

I asked Copilot to explain the use of these two objects:

SemaphoreSlim — what it is and why we use it here

What it is: a lightweight semaphore that supports async waits via WaitAsync and is intended for intra-process concurrency control. You can think of it as a lock that can have a count > 1 (we use count = 1 so it behaves like a mutex), but importantly it works with async/await without blocking thread-pool threads.

Why used here: We need an async-friendly mutual exclusion so only one download of the same blob happens at once. If two requests for the same asset arrive simultaneously we want one to download and the others to wait. SemaphoreSlim.WaitAsync allows awaiting the lock instead of blocking a thread (unlike Monitor/lock), which keeps the server scalable under load.

Using a per-asset SemaphoreSlim (one per cache file) allows parallel downloads for different assets while serializing downloads for the same asset.

ConcurrentDictionary — what it is and why we use it here

What it is: a thread-safe dictionary implementation for concurrent read/write access without needing an external lock. Methods like GetOrAdd are atomic.

Why used here: We store a SemaphoreSlim per cache file path. Multiple threads may attempt to add or retrieve the semaphore at the same time, so we need a thread-safe structure to avoid race conditions when creating or fetching the semaphore. GetOrAdd returns the same semaphore to all concurrent callers for a given key, ensuring they synchronize on the same per-key lock.

How they work together in the cache code

Flow:

  • Check if cached file exists and is fresh; if yes, return a read-only FileStream immediately.
  • Otherwise, call _locks.GetOrAdd(finalPath, _) to get/create a SemaphoreSlim for that path.

[It carried on for a while, with benefits, tradeoffs, caveats and alternatives]

Summary

  • SemaphoreSlim gives an async-friendly per-asset mutex so only one download runs for a given path.
  • ConcurrentDictionary provides safe concurrent creation and lookup of those per-asset semaphores.
  • Together they prevent duplicate downloads, avoid blocking threads, and allow parallelism for different assets — which is exactly what we need in a file cache that should be efficient and scalable.

If you’d like, I can:

  • Change the implementation to an AsyncLazy/Task-based approach (share the download Task instead of semaphores).
  • Add disposal of SemaphoreSlim after TryRemove.
  • Add a simple in-memory eviction of semaphore entries or convert to storing Lazy<Task> to simplify resource usage.

Which would you prefer?

It’s a veritable avalanche of justification. It all made perfect sense, too. In this way, I can see how assistants like Copilot can be useful training tools for inexperienced coders, as long as they’re able to sift through the massive answers and constant pushing of what Sam Altman calls “usage-maxxing” - where it can’t answer a question without also offering to perform task XYZ next (You can, of course, just ask it to stop doing that, and it will do so).

Going deeper

I reviewed the code it output (but didn’t run it). It all looked pretty fine. We were about two minutes in from where we began, and my original objective (implementing caching on my media service) - something that would probably have taken me an a afternoon if I did it myself, with the potential to get into the weeds reading reference material for the most min-maxed approach - was, ostensibly at least, complete. I’d call that a win.

Looking at its three suggestions for improvements, I thought the second (adding disposal of SephamoreSlim after TryRemove) sounded sensible, so asked it to proceed.

It added the following to the finally block in FileBlobCache:

1
2
3
4
5
6
    sem.Release();
    // clean up semaphore entry (non-blocking) and dispose
    if (_locks.TryRemove(finalPath, out var removedSem))
    {
        removedSem.Dispose();
    }

Copilot then had an idea:

Also, since FileBlobCache manages disposable resources (the SemaphoreSlim instances), we should technically implement IDisposable to clean up any remaining semaphores when the cache is disposed. Would you like me to add that as well?

This also sounds sensible. OK then.

So Copilot implements IDisposable in FileBlobCache by adding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void Dispose()
{
    if (!_disposed)
    {
        _disposed = true;

        // Dispose all semaphores
        foreach (var sem in _locks.Values)
        {
            try
            {
                sem.Dispose();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error disposing semaphore in cache cleanup");
            }
        }
        
        _locks.Clear();
    }
}

It also added to the top of the GetOrDownloadAsync method a check to see whether the _locks has already been disposed:

1
2
3
4
if (_disposed)
{
    throw new ObjectDisposedException(nameof(FileBlobCache));
}

Losing track

A fair amount of code had now been written for this small app in the last few minutes. I didn’t quite realise it, but I was on the verge of crossing past the event horizon into the dangerous zone where the AI agent (on the face of it) had a better understanding of what this new version of the codebase was doing than I did.

Reviewing the latest additions, I mistakenly thought that Dispose() was being called in the finally block, and so setting _disposed to true after the first media request and nuking the app. It would be the sort of dumb piece of code logic that you wouldn’t expect a human to do, but that I would fully expect an over-confident AI to do. I was wrong (Dispose() only gets called on shutdown in this case), but asked Copilot anyway.

What happened next was interesting. It pointed out my mis-comprehension of the code, but also suddenly noticed a totally separate problem all by itself, and fixed it without asking:

I’ll update FileBlobCache.cs to stop disposing per-key semaphores immediately in the finally block (to avoid races with waiters). Instead we’ll remove the entry and let the global Dispose() clean them up at shutdown. Expected outcome: safer behavior under concurrency. I’ll apply a small patch to the finally block.

I changed the code to stop disposing semaphores in the finally block.

Rationale:

  • Disposing a SemaphoreSlim immediately after a Release can race with other waiters that are still being resumed (they may still be inside WaitAsync and could observe a disposed semaphore).
  • It’s safer to remove the entry from the dictionary and let the global Dispose() iterate _locks.Values and dispose them once the cache is being disposed (which you already had implemented).
  • The lifecycle now is:
    • Per-request: create/get semaphore, WaitAsync, Release, remove from dictionary (no dispose).
    • Shutdown / cache disposal: Dispose() disposes any remaining semaphores and clears the dictionary; GetOrDownloadAsync will throw ObjectDisposedException after disposal to prevent new operations.

I’d gotten officially overtaken by all the edits now. I realised I was getting caught in the usage-maxx, and so ignored its further ‘suggestions’ to change the controller to return a PhysicalFileResult rather than the File it was using previously. File had been working OK for the last 9 months, so I didn’t see a need to turn it over now.

It did make me look again at the FileStream code, though. I noticed that it was making use of FileStream without a using block or statement. This is the sort of red flag you might typically spot when reviewing a PR, so I asked Copilot. It said that I’m correct in spotting this, and we’re relying on the framework to handle any Streams after they’re out of use, which isn’t something we should ever just assume will happen. It once again recommended implementing PhysicalFileResult to get around this.

This time, I acquiesced, and it made more changes.

FileBlobCache now returned a filepath to the cached file, rather than a FileStream:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// If cached and fresh, return path
if (File.Exists(finalPath))
{
    var fi = new FileInfo(finalPath);
    var age = DateTime.UtcNow - fi.LastWriteTimeUtc;
    if (age <= _ttl)
    {
        _logger.LogDebug("Cache hit for {AssetPath}", assetPath);
        return finalPath;
    }
    ..
}

..

// Download to a temp file then move into place
var tempPath = finalPath + ".download" + Guid.NewGuid().ToString("N");
try
{
    _logger.LogInformation("Downloading blob to cache: {AssetPath} -> {Path}", assetPath, finalPath);
    using (var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
    {
        await blobClient.DownloadToAsync(fs, cancellationToken).ConfigureAwait(false);
    }

    // Move into place (overwrite if necessary)
    if (File.Exists(finalPath)) File.Delete(finalPath);
    File.Move(tempPath, finalPath);

    // Return the cached file path
    return finalPath;
}

And the MediaController now returned a PhysicalFile:

1
2
3
4
5
6
7
8
9
// Download or serve from cache
var blobClient = container.GetBlockBlobClient(assetPath);
if (await blobClient.ExistsAsync())
{
    // Use local file cache to speed repeated reads. This returns the cached file path.
    var cachedPath = await _fileBlobCache.GetOrDownloadAsync(assetPath, blobClient).ConfigureAwait(false);
    // Return a PhysicalFile so the framework opens and disposes the file stream and supports ranges
    return PhysicalFile(cachedPath, mimeType, enableRangeProcessing: true);
}

Benefits that aren’t necessarily benefits

One of the reasons Copilot thought this solution was so great was because we can now enable ‘range processing’, which means the client can request certain parts of the file rather than having to retrieve the whole file from the beginning. This is beneficial when ‘seeking’ through video content, or restarting failed downloads.

The problem is…all my media that I’m serving through this application are small image files! This feature is totally useless. I explained this to Copilot, and it sheepishly informed me that leaving it enabled doesn’t harm my app, and said something along the lines of “at least it’s future-proofed now”.

Testing

Copilot was still suggesting more changes, though by this point they were getting a bit radical and I could tell would involve major rewrites. It was time to actually give this a go.

Bundle up, deploy…it doesn’t work! All images whirr away before resulting in a HTTP 500. Very confusing. I dug through the logging to find the server error. The resource the server was trying to find had a non-rooted path.

After all that, the dodgy code was actually added right in Copilot’s first edit. It was staring me in the face the whole time:

1
2
3
4
5
6
7
8
9
10
public FileBlobCache(IConfiguration configuration, ILogger<FileBlobCache> logger)
{
    ..
    
    var section = configuration.GetSection("MediaCache");
    _cacheRoot = section.GetValue<string>("CacheDirectory") 
        ?? Path.Combine(Directory.GetCurrentDirectory(), "media-cache");
    
    ..
}

Whilst Copilot did engage in a bit of defensive programming here by including a fallback value for if the media-cache value hadn’t been set in appSettings, what it got wrong was that if it was set, it would simply use the actual string media-cache as the full path to the media cache folder, resulting in the ‘not rooted’ error message. All that structure, all those steps taken to avoid race conditions, and the problem was a very simple Introduction to Programming class type of error. I pointed this out to Copilot, and it dutifully fixed it (after congratulating and thanking me profusely for pointing out the mistake), and now the caching works.

Thoughts on ‘Vibe Coding’

Reading back what I’ve written so far, it comes across as a bit critical of Copilot, and one could infer from this a pessimism around AI code assistants more generally. Actually, to do so would be to infer the complete opposite of my actual opinion. What I actually just witnessed was, if you really think about it, pretty incredible.

Considering a coding agent’s capabilities in context

What Copilot was able to do in a few minutes was take an application it had never seen before, with a usage case it didn’t know, written by an individual whose coding habits may have been unconventional or straight up wrong (I don’t think they were, but you never know), and implement a working server caching function, then talk me through the decisions it made. It even spotted and fixed its own bugs along the way. All I gave it was three sentences and a very rough instruction.

People are quick - online in particular - these days to write off AI code assistants as producers of un-buildable code full of mistakes which need constant hand-holding. They’re right in that that sort of thing is indeed common with these tools, but it’s actually not much different from what we got before code assistants, where more junior programmers would produce code of a similar standard… but also take all day to do it, and wouldn’t necessarily immediately understand what they did wrong when it was pointed out. I actually think a lot of the scepticism from programmers is a defensive measure, a ‘cope’ to avoid having to think about what the advent of these tools might mean, and how their own way of doing things may need to soon change.

What I think happens next

I don’t think coding agents are going anywhere (unless the whole AI thing really does prove a flash in the pan, which I seriously doubt), though when we reach the seemingly likely ‘bubble burst’ moment, I think access to the tools might become more expensive and professionally oriented. I think we might get big changes to how four separate roles in the SDLC work (it’ll be more than this, but I’m thinking about core programming in particular).

Experienced developers

Expect senior devs to spend more of their time ‘shepherding’ AI agents. Even in the fairly simple use-case discussed in this blog post, the code assistant made mistakes and required a human who understood it all (and the actual software context) to check things over and course correct. Also important is dialogue with the agent itself to understand the whys of what it was doing, and second guess it when it starts trying to over-engineer or introduce functionality that doesn’t serve a purpose for the use case (as that leads to bloat and increases the maintenance complexity). Productivity actually increases hugely, though it might sound a bit more boring now you’re not at the coalface working out solutions to problems and implementing them elegantly.

Eventually, of course, you’d expect the code agents to make fewer-to-no mistakes and not need hand-holding. All bets are off for all flavours of developer when we get to that point, though.

Junior developers

The outlook does seem quite bleak right now. With code agents able to develop functionality beyond the capabilities of your average graduate dev, in more or less any language - even with bugs and non sequiturs - in an utter fraction of the time and for a fraction of the cost, there doesn’t appear to be a need for a junior developer particularly. What I do think, though, is a pivot by younger devs new to the game toward using these tools and getting the most out of them right from the very beginning. It paints a rather strange and scary scenario where developers use AI to push out extremely complicated code, of which they more than likely understand very little. It has probably already started.

It’s tempting to think here “Well, software quality will nosedive. Senior devs will still be needed to bail them all out”, but I think this is a ‘cope’ too. What I think is more likely is a drop in software quality in general, but not a cataclysm. People will just get used to stuff not working as well as they remember it used to. This, then, is bad news for experienced (and expensive) developers.

I’ll admit that above is a fairly non-committal conjecture on who loses out the most between junior and senior developers. Let’s change that by explaining who I think is without a doubt, certainly on the scrapheap:

‘Coasting’ mid-experience developers

Anyone who’s worked in software development will know the type. Ten-plus years in the business, never progressed particularly far (nor is that interested in doing so), is still working on that one system all this time, never really upskilled in different technologies, and definitely hasn’t used a coding assistant, nor really sees much point in doing so.

Unless they start studying these tools and making use of them as a productivity multiplier (whether they’re able to properly police outputs or not), it’s over. They will be unable to do the job of a senior developer, and will be severely outgunned by junior developers. In the private sector, they simply won’t be able to survive and I expect will end up congregating in the safety of places like the civil service, if they haven’t changed career entirely.

Lastly…

The Business

With productivity increasing, costs for software delivery are likely to go down. For traditional ‘software houses’, I don’t see them being able to operate the way they do currently unless the volume of incoming work increases in inverse proportion to delivery pricing. Now, it’s definitely possible that work volume does increase as the possibilities for software development grow in scope (if there are legacy systems in need of that much-delayed rewrite, now is the time), but it does seem on balance less likely than software delivery simply not requiring armies of coders any more, and the industry changes structure as a result. Giants like TCS seem to sniff this. What a software contractor industry of the future might look like is small groups of greybeards building software at a similar volume to a 50-developer business today.

Alternatively, you could speculate that software houses are for the birds entirely. Why would a business pay for their big online systems projects to be developed by a contractor with a lot of muscle and manpower when you could just hire two developers and bring it all in-house? Difficult to tell what is the more likely scenario this point.

Time to retrain as an electrician?

The short answer is yes. If nothing else It’s a useful skill to have.

If you have any thoughts, send me an email