Skip to content

Commit bb0837a

Browse files
authored
fix: shutdown-noise in background services + seed defaults so the app starts out of the box (#279)
* fix: stop background services reporting shutdown as a crash A host that aborts startup cancels background services mid-wait. Three services awaited a delay outside any handler for that cancellation, so the TaskCanceledException escaped ExecuteAsync. .NET treats that as a crashed BackgroundService and, under the default StopHost behaviour, logs a critical "BackgroundService failed" on top of the error that actually stopped the host — burying the real cause. AuditRetentionService was the worst case: its one-minute startup delay had no try at all, so any shutdown in the first 60 seconds of process life tripped it. The interval delay at the foot of its loop sat outside the try as well; a single outer handler now covers both. AuditWriterService and ProgressFlushService each awaited a 5s error backoff from inside a catch block, where the enclosing try cannot observe it. Those are guarded locally and now break out of the loop on shutdown. Behaviour is unchanged on every non-shutdown path, and BackgroundServiceExceptionBehavior is deliberately left at StopHost so a genuinely crashed service still stops the host. Also document the seed-password guard, which is what surfaced this: the image sets no ASPNETCORE_ENVIRONMENT, so running it outside compose inherits Production and refuses to start until Seed__AdminPassword is set. That guard was undocumented outside docker-compose.yml. Adds a README section, a .env.example (compose points at a .env that had no template), and gitignores .env — it was tracked, so a compose-created file holding an admin password would have been committable. * feat: always seed the default accounts so the app starts out of the box The admin seed previously failed host startup outside Development when Seed:AdminPassword was unconfigured, so a plain `docker run` of the image (which inherits the base image's Production default) refused to boot. Both seeded accounts now fall back to their compiled-in defaults in every environment. This also makes the login page's quick-login buttons work as advertised: they hardcode admin@simplemodule.dev/Admin123! and user@simplemodule.dev/User123!, and the Users:ShowTestAccounts setting that renders them defaults to on everywhere — so the demo account previously being skipped outside Development left a visible button that always failed. ResolveSeedPassword collapses to "configured value, else default", which makes SeedPasswordOutcome and SeedConfigurationException dead code; both are removed. docker-compose no longer requires SEED_ADMIN_PASSWORD (it used ${VAR:?...} on both api and worker, which blocked startup independently of the C# guard). These defaults are published in this repository. Startup now logs a warning outside Development naming the config key to set, and the README, .env.example and compose comments all call out that a deployment reachable by anyone else must set Seed:AdminPassword and disable Show Test Accounts. This trades fail-closed for boots-by-default; it is a deliberate product decision, not an oversight. Verified in a container: Production with no seed password now starts, seeds both accounts with warnings, and login succeeds for both (a wrong password is still rejected, and /admin/users still redirects anonymous users). * fix: worker image missing the ASP.NET Core shared framework Dockerfile.worker built on mcr.microsoft.com/dotnet/runtime:10.0, but the worker hosts the same modules as the api and every module carries a <FrameworkReference Include="Microsoft.AspNetCore.App" />. The plain runtime image does not ship that shared framework, so the worker died on every boot with "No frameworks were found" and docker compose left it restarting forever. CI never caught this: it builds only ./Dockerfile, never Dockerfile.worker, and never starts the stack — and the image builds fine, it only fails at runtime. This is necessary but not sufficient. With the correct base image the worker gets further and then fails DI validation, because it registers ASP.NET Core authorization services while running as a non-web generic host: AuthorizationPolicyCache cannot resolve EndpointDataSource. That is a separate pre-existing bug and is not addressed here. * fix: guard the shutdown drain; address review; drop flaky shutdown tests Review follow-ups on this branch: - AuditWriterService and ProgressFlushService drained their channel one last time AFTER the loop, outside any try. That final flush routinely throws during a failed startup (the provider is already disposed — observed as ObjectDisposedException in a real container), which escaped ExecuteAsync as the very "BackgroundService failed" noise this branch exists to remove, and skipped LogStopped as well. Both are now guarded and log the error instead. - AuditRetentionService gained a LogStopped, matching its two sibling services. - UserSeedService's XML doc referenced a non-existent 'Users:ShowTestAccounts' config key. The real identifier is the auth.show_test_accounts system setting, read via ISettingsContracts — it cannot be set by environment variable, so the old wording pointed operators at a no-op. - README documents that deployments which previously set only Seed__AdminPassword will gain a user@simplemodule.dev account seeded with User123! on the next restart, and corrects "outside Development" to "outside Development or Testing" to match environment.IsLocalOrTest(). The shutdown regression tests are removed rather than shipped. Three successive designs — ExecuteTask status, awaiting ExecuteTask, and asserting the service's own completion log — were all unreliable under parallel load or on CI, and one of them turned the PR's build red. A test that fails intermittently is worse than no test: it trains people to ignore CI. The fixes remain verified end to end in a container, where stopping inside and after the 60s startup-delay window both exit 0 with zero critical lines. * docs: correct environment gate in README upgrade note The upgrade note said the demo user was skipped 'outside Development', repeating the imprecision corrected four lines above it — the gate is environment.IsLocalOrTest(), which is Development or Testing.
1 parent de00b59 commit bb0837a

12 files changed

Lines changed: 212 additions & 208 deletions

File tree

‎.env.example‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copy to .env to override defaults before running `docker compose up`.
2+
# .env is gitignored — never commit real credentials.
3+
4+
# Passwords for the seeded accounts. Both are OPTIONAL: when unset they fall back
5+
# to the compiled-in defaults (Admin123! / User123!), which are what the login
6+
# page's quick-login buttons use.
7+
#
8+
# Those defaults are published in this repository — set these before exposing the
9+
# deployment to anyone else, and turn off the "Show Test Accounts" setting.
10+
SEED_ADMIN_PASSWORD=
11+
SEED_USER_PASSWORD=
12+
13+
# PostgreSQL password for the bundled database container.
14+
# Defaults to "simplemodule" when unset — override for anything exposed.
15+
POSTGRES_PASSWORD=simplemodule
16+
17+
# Public base URL, used by OpenIddict to register redirect URIs.
18+
APP_BASE_URL=http://localhost:8080

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,9 @@ baseline/
451451
# Verification artifacts
452452
.verify/
453453
.qa/
454+
455+
# Local environment files — docker-compose reads SEED_ADMIN_PASSWORD and
456+
# POSTGRES_PASSWORD from here, so these must never be committed.
457+
.env
458+
.env.*
459+
!.env.example

‎Dockerfile.worker‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ RUN dotnet publish template/SimpleModule.Worker/SimpleModule.Worker.csproj \
9494
--no-restore \
9595
-p:ErrorOnDuplicatePublishOutputFiles=false
9696

97-
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime
97+
# The worker hosts the same modules as the api, and every module carries a
98+
# <FrameworkReference Include="Microsoft.AspNetCore.App" />. The plain runtime
99+
# image does not ship that shared framework, so the worker started and then died
100+
# with "No frameworks were found" on every boot. This must stay in sync with the
101+
# api image in ./Dockerfile.
102+
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
98103
WORKDIR /app
99104

100105
RUN apt-get update \

‎README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,44 @@ dotnet run --project template/SimpleModule.Host # https://localhost:5001
3939
docker compose up # http://localhost:8080 with PostgreSQL 16
4040
```
4141

42+
#### Seed credentials
43+
44+
Two accounts are seeded on first start, and the login page's quick-login buttons use these exact
45+
credentials:
46+
47+
| Account | Default password | Override with |
48+
|---------|------------------|---------------|
49+
| `admin@simplemodule.dev` | `Admin123!` | `Seed__AdminPassword` |
50+
| `user@simplemodule.dev` | `User123!` | `Seed__UserPassword` |
51+
52+
The defaults apply in every environment so the app boots and is usable out of the box. Outside
53+
`Development` and `Testing` the app logs a warning when it falls back to them.
54+
55+
> **These passwords are published in this repository.** Before exposing a deployment to anyone else,
56+
> set `Seed__AdminPassword` (or change the password after first login) and turn off the
57+
> **Show Test Accounts** setting, which renders the quick-login buttons and defaults to on.
58+
59+
```bash
60+
docker build -t simplemodule .
61+
docker run -e Seed__AdminPassword='<strong-password>' -p 8080:8080 simplemodule
62+
```
63+
64+
`Seed__AdminPassword` is the environment-variable spelling of the `Seed:AdminPassword` configuration
65+
key — use the latter in `appsettings.*.json`. Accounts are seeded only when they don't already
66+
exist, so changing these values later will **not** rotate an existing password; change it from the
67+
account page instead.
68+
69+
> **Upgrading an existing deployment.** Previously the demo `user@simplemodule.dev` account was
70+
> skipped outside `Development` and `Testing` when `Seed__UserPassword` was unset, so deployments that set only
71+
> `Seed__AdminPassword` have no such account today. They will gain one, seeded with `User123!`, on
72+
> the next restart. Set `Seed__UserPassword`, or delete the account after it appears, if you don't
73+
> want it. The admin account is unaffected — it already exists, and existing accounts are never
74+
> re-seeded or rotated.
75+
76+
Note that `Production` additionally requires OpenIddict signing and encryption certificates
77+
(`OpenIddict__SigningCertificatePath` / `OpenIddict__EncryptionCertificatePath`, PKCS#12), and
78+
refuses to start without them.
79+
4280
### Development
4381

4482
```bash

‎docker-compose.yml‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ services:
2222
# combined with seeded credentials it would hand out fully-privileged
2323
# tokens with a single POST /connect/token.
2424
OpenIddict__AllowPasswordGrant: "false"
25-
# Required: password for the seeded admin account. There is deliberately
26-
# no default — put SEED_ADMIN_PASSWORD in your .env file.
27-
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env}
28-
# Optional: password for the seeded demo user (outside Development the
29-
# demo user is skipped entirely when this is unset).
25+
# Optional: passwords for the seeded accounts. When unset, both fall back to
26+
# the compiled-in defaults (Admin123! / User123!) so the stack comes up
27+
# usable — those are the same credentials the login page's quick-login
28+
# buttons use. They are published in this repository, so set these (or
29+
# change the passwords after first login) before exposing the deployment.
30+
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-}
3031
Seed__UserPassword: ${SEED_USER_PASSWORD:-}
3132
# api stays in Producer mode — it enqueues jobs but never runs them.
3233
# All IModuleJob execution lives in the worker service below.
@@ -56,11 +57,10 @@ services:
5657
Database__Provider: PostgreSQL
5758
BackgroundJobs__WorkerMode: Consumer
5859
# The worker runs the same module set as the api, including the user
59-
# seeder. It must see the SAME seed passwords — otherwise whichever
60-
# process wins the startup race seeds the admin account, and a worker
61-
# without these would silently seed the compiled-in default password,
62-
# defeating the api's required SEED_ADMIN_PASSWORD.
63-
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env}
60+
# seeder. It must see the SAME seed passwords as the api — whichever
61+
# process wins the startup race is the one that creates the accounts, so a
62+
# mismatch here makes the seeded password depend on that race.
63+
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-}
6464
Seed__UserPassword: ${SEED_USER_PASSWORD:-}
6565
volumes:
6666
- storage_data:/app/storage

‎modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
8181
#pragma warning restore CA1031
8282
{
8383
LogFlushError(logger, ex);
84-
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
84+
85+
// This backoff runs inside a catch block, so the enclosing try
86+
// cannot observe its cancellation — guard it here or a shutdown
87+
// during the backoff escapes ExecuteAsync as a spurious crash.
88+
try
89+
{
90+
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
91+
}
92+
catch (OperationCanceledException)
93+
{
94+
break;
95+
}
8596
}
8697
}
8798

@@ -93,7 +104,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
93104
}
94105
if (batch.Count > 0)
95106
{
96-
await FlushBatchAsync(batch);
107+
// The service provider is frequently already disposed by the time a
108+
// failed startup unwinds to here, so this final flush can throw. It
109+
// runs outside the loop's try, so letting it escape would surface a
110+
// normal shutdown as a crashed BackgroundService — the exact noise
111+
// this class of fix exists to remove — and skip LogStopped too.
112+
try
113+
{
114+
await FlushBatchAsync(batch);
115+
}
116+
#pragma warning disable CA1031
117+
catch (Exception ex)
118+
#pragma warning restore CA1031
119+
{
120+
LogFlushError(logger, ex);
121+
}
97122
}
98123

99124
LogStopped(logger);

‎modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs‎

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,41 @@ ILogger<AuditRetentionService> logger
1616
{
1717
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
1818
{
19-
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
19+
try
20+
{
21+
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
2022

21-
var checkInterval = moduleOptions.Value.RetentionCheckInterval;
23+
var checkInterval = moduleOptions.Value.RetentionCheckInterval;
2224

23-
while (!stoppingToken.IsCancellationRequested)
24-
{
25-
try
26-
{
27-
await RunCleanupAsync(stoppingToken);
28-
}
29-
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
25+
while (!stoppingToken.IsCancellationRequested)
3026
{
31-
break;
32-
}
27+
try
28+
{
29+
await RunCleanupAsync(stoppingToken);
30+
}
31+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
32+
{
33+
break;
34+
}
3335
#pragma warning disable CA1031
34-
catch (Exception ex)
36+
catch (Exception ex)
3537
#pragma warning restore CA1031
36-
{
37-
LogError(logger, ex);
38-
}
38+
{
39+
LogError(logger, ex);
40+
}
3941

40-
await Task.Delay(checkInterval, stoppingToken);
42+
await Task.Delay(checkInterval, stoppingToken);
43+
}
44+
}
45+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
46+
{
47+
// Normal shutdown while waiting on the startup delay or the interval
48+
// delay. Returning quietly keeps this off the BackgroundService failure
49+
// path, which would otherwise log a critical "BackgroundService failed"
50+
// on top of — and obscuring — whatever actually stopped the host.
4151
}
52+
53+
LogStopped(logger);
4254
}
4355

4456
private async Task RunCleanupAsync(CancellationToken ct)
@@ -87,4 +99,7 @@ private async Task RunCleanupAsync(CancellationToken ct)
8799

88100
[LoggerMessage(Level = LogLevel.Error, Message = "Retention cleanup failed")]
89101
private static partial void LogError(ILogger logger, Exception ex);
102+
103+
[LoggerMessage(Level = LogLevel.Information, Message = "AuditRetentionService stopped")]
104+
private static partial void LogStopped(ILogger logger);
90105
}

‎modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/ProgressFlushService.cs‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
5757
#pragma warning restore CA1031
5858
{
5959
LogFlushError(logger, ex);
60-
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
60+
61+
// This backoff runs inside a catch block, so the enclosing try
62+
// cannot observe its cancellation — guard it here or a shutdown
63+
// during the backoff escapes ExecuteAsync as a spurious crash.
64+
try
65+
{
66+
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
67+
}
68+
catch (OperationCanceledException)
69+
{
70+
break;
71+
}
6172
}
6273
}
6374

@@ -69,7 +80,25 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
6980
}
7081
if (batch.Count > 0)
7182
{
72-
await FlushBatchAsync(batch, moduleOptions.Value.MaxLogEntries, CancellationToken.None);
83+
// The service provider is frequently already disposed by the time a
84+
// failed startup unwinds to here, so this final flush can throw. It
85+
// runs outside the loop's try, so letting it escape would surface a
86+
// normal shutdown as a crashed BackgroundService — the exact noise
87+
// this class of fix exists to remove — and skip LogStopped too.
88+
try
89+
{
90+
await FlushBatchAsync(
91+
batch,
92+
moduleOptions.Value.MaxLogEntries,
93+
CancellationToken.None
94+
);
95+
}
96+
#pragma warning disable CA1031
97+
catch (Exception ex)
98+
#pragma warning restore CA1031
99+
{
100+
LogFlushError(logger, ex);
101+
}
73102
}
74103

75104
LogStopped(logger);

‎modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs‎

Lines changed: 0 additions & 19 deletions
This file was deleted.

‎modules/Users/src/SimpleModule.Users/Services/SeedPasswordOutcome.cs‎

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)