Skip to content

Commit 475400f

Browse files
committed
Updates
1 parent 2fad833 commit 475400f

7 files changed

Lines changed: 176 additions & 9 deletions

File tree

readme.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
2424
| [Shiny.Net.HttpServer.WebDav](https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav) | A WebDAV (RFC 4918) class 1 & 2 server over a directory — mount an app's storage in Finder, Windows Explorer or any WebDAV client, and open the same URL in a browser for a file manager with upload, rename and delete |
2525
| [Shiny.Net.HttpServer.Grpc](https://www.nuget.org/packages/Shiny.Net.HttpServer.Grpc) | gRPC and gRPC-Web — unary, streaming and bidirectional methods over the same HTTP/2 stack, with serialization you supply |
2626
| [Shiny.Net.HttpServer.Discovery](https://www.nuget.org/packages/Shiny.Net.HttpServer.Discovery) | mDNS/DNS-SD (Bonjour) — advertises the server on the local link and finds the ones other devices advertise, so nobody has to type an IP address |
27-
| [Shiny.Net.HttpServer.Mobile](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mobile) | Mobile lifecycle on Shiny.Core — stop on background and start on resume, an Android foreground service to keep serving, rebind when the device changes network, and a check for the manifest entries that silently break local networking. iOS and Android, with or without MAUI |
27+
| [Shiny.Net.HttpServer.Mobile](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mobile) | Mobile lifecycle on Shiny.Core — stop on background and start on resume, an Android foreground service that follows the server's own running state to keep serving, an iOS resume that restarts the server the suspension took, rebind when the device changes network, and a check for the manifest entries that silently break local networking. iOS and Android, with or without MAUI |
2828
| [Shiny.Net.HttpServer.Testing](https://www.nuget.org/packages/Shiny.Net.HttpServer.Testing) | An `HttpClient` wired to the server through memory — endpoint tests with no port, no listener and no socket, but the real parser, router and middleware |
2929
| [Shiny.Net.HttpServer.Tunnels](https://www.nuget.org/packages/Shiny.Net.HttpServer.Tunnels) | Agent-backed tunnels — supervises `cloudflared`, `ngrok` or `tailscale` and reports the public URL. Desktop, server and CLI; on a phone use the SSH provider or the relay |
3030
| [Shiny.Net.HttpServer.CommandLine](https://www.nuget.org/packages/Shiny.Net.HttpServer.CommandLine) | A .NET tool — `shinyhttpserver` — that serves a directory over WebDAV, so one address is both a browser file manager (browse, upload, rename, delete) and a drive Finder or Explorer can mount, with basic auth, per-operation permissions, and a QR code in the banner so a phone can scan its way in — `--tunnel` swaps the LAN address for a public pinggy.io tunnel so the phone need not be on the same network |
@@ -104,7 +104,8 @@ builder.Services.AddShinyHttpServer(
104104
{
105105
http.Options.Address = IPAddress.Any;
106106

107-
// Stops on background, starts on resume, rebinds when the network changes.
107+
// Keeps serving in the background on Android through a foreground service; on iOS, where
108+
// nothing can, restores the server on resume if it was running. Both rebind on a network change.
108109
http.AddHttpServerLifecycle(o => o.BackgroundMode = BackgroundServerMode.KeepAlive);
109110

110111
// Advertises on the local link, so the other device does not need an IP address.

skills/shiny-httpserver/SKILL.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,7 +1192,7 @@ services.AddShinyHttpServer(
11921192

11931193
http.AddHttpServerLifecycle(o =>
11941194
{
1195-
o.BackgroundMode = BackgroundServerMode.Stop; // or KeepAlive on Android
1195+
o.BackgroundMode = BackgroundServerMode.Stop; // or KeepAlive
11961196
o.RestartOnConnectivityChange = true; // default
11971197
});
11981198
},
@@ -1204,9 +1204,24 @@ Needs a Shiny host (`UseShiny()` in `MauiProgram`) — that is what delivers the
12041204

12051205
- **iOS suspends the app within seconds of backgrounding**, and no background mode legitimately keeps
12061206
a listener answering. `Stop` makes that visible: `IsRunning` goes false, the UI can say so, and
1207-
clients get a refused connection instead of a hang. `KeepAlive` behaves the same once suspended.
1207+
clients get a refused connection instead of a hang.
12081208
- **Android `KeepAlive` starts a foreground service** with a permanent notification, which is the
12091209
only supported way to hold a socket open in the background. That notification is the deal.
1210+
- **iOS `KeepAlive` restores the server on resume** instead. It cannot keep the listener open, so it
1211+
does the other useful thing: the server is left running as the app goes away (a few seconds is
1212+
often enough to finish the request in flight) and is *restarted* when the app comes back. This
1213+
matters because the suspension takes the socket while `IsRunning` goes on saying `true`, so an app
1214+
that only re-checked `IsRunning` would find nothing wrong and serve nothing — and calling
1215+
`StartAsync()` yourself would not help either, since it is idempotent and agrees with the stale
1216+
state. `RestartAsync()` is what fixes it, and this does it for you.
1217+
- **This is not `AlwaysStartOnForeground`.** A server that was off when the user left stays off; only
1218+
one that was running is put back. Set `AlwaysStartOnForeground = true` if you want it on at every
1219+
resume regardless — that overrides a toggle the user switched off, so make it a deliberate choice.
1220+
- **Both platforms follow the server, not just the app's transitions.** On Android, stopping the
1221+
server while the app is backgrounded takes the notification down with it and starting it brings
1222+
the service up, so Android does not reclaim the process and kill the listener; on iOS the same
1223+
transitions decide whether the resume restores it. Nothing to call — the package tracks
1224+
`HttpServer.StateChanged` — so an app whose server is a toggle does not need its own bookkeeping.
12101225
- A phone's address changes when it moves. The lifecycle package rebinds on connectivity changes;
12111226
the core has the same thing without Shiny.Core via `options.RebindOnNetworkChange`, and raises
12121227
`server.NetworkAddressesChanged` either way so a QR code or advertisement can be refreshed.

src/Shiny.Net.HttpServer.Mobile/HttpServerLifecycleOptions.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,24 @@ public enum BackgroundServerMode
2020
/// <para>
2121
/// On Android this starts a foreground service, which is the only supported way to keep a
2222
/// socket answering with the app in the background — and it means a permanent notification,
23-
/// which is the deal Android offers. On iOS nothing can be done: this behaves as
24-
/// <see cref="Stop"/> once the process is actually suspended, which is why the server is left
25-
/// running rather than stopped — a few seconds of background execution is sometimes exactly
26-
/// enough to finish the request in flight.
23+
/// which is the deal Android offers.
24+
/// </para>
25+
/// <para>
26+
/// On iOS nothing keeps the listener open, so this means the other half of the promise: the
27+
/// server is left running as the app goes away — a few seconds of background execution is
28+
/// sometimes exactly enough to finish the request in flight — and it is <b>restarted when the
29+
/// app comes back</b>, because the suspension took the socket while leaving
30+
/// <see cref="HttpServer.IsRunning"/> saying otherwise. A server that was serving when the user
31+
/// switched away is serving again by the time they are looking at the app. One that was
32+
/// switched off before they left stays off; this is not
33+
/// <see cref="HttpServerLifecycleOptions.AlwaysStartOnForeground"/>.
34+
/// </para>
35+
/// <para>
36+
/// Both platforms follow the server, not only the app's transitions: on Android, stop the server
37+
/// while the app is backgrounded and the notification goes with it rather than claiming to serve
38+
/// nothing, and start it while backgrounded and the service comes up rather than leaving the
39+
/// process to be reclaimed; on iOS the same transitions decide whether the resume restores it.
40+
/// Nothing to call for either.
2741
/// </para>
2842
/// </summary>
2943
KeepAlive,

src/Shiny.Net.HttpServer.Mobile/HttpServerLifecycleTask.cs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ILogger<HttpServerLifecycleTask> logger
2121
) : ShinyLifecycleTask, IDisposable
2222
{
2323
bool stoppedByLifecycle;
24+
bool restoreOnForeground;
2425
bool subscribed;
2526

2627
public override void Start()
@@ -32,6 +33,10 @@ public override void Start()
3233
connectivity.Changed += this.OnConnectivityChanged;
3334
this.subscribed = true;
3435
}
36+
37+
// The foreground service follows the server, not only the app's lifecycle transitions -
38+
// see OnServerStateChanged for why that difference matters.
39+
server.StateChanged += this.OnServerStateChanged;
3540
}
3641

3742
protected override void OnStateChanged(bool backgrounding)
@@ -67,6 +72,7 @@ async Task OnBackgroundingAsync()
6772

6873
case BackgroundServerMode.KeepAlive when server.IsRunning:
6974
this.StartBackgroundExecution();
75+
this.TrackForRestore(true);
7076
break;
7177
}
7278
}
@@ -75,6 +81,21 @@ async Task OnForegroundingAsync()
7581
{
7682
this.StopBackgroundExecution();
7783

84+
if (this.restoreOnForeground)
85+
{
86+
this.restoreOnForeground = false;
87+
88+
// Restarted, not started. The server object was never told anything happened, so it
89+
// still reports Running - and StartAsync, being idempotent, would agree with it and do
90+
// nothing, leaving the dead listener dead. RestartAsync unbinds and binds again, which
91+
// is also the right call if the suspension took the socket but the server survived, and
92+
// is harmless if it was in fact stopped underneath us.
93+
logger.LogInformation("Restarting the server after the background suspension");
94+
95+
await server.RestartAsync().ConfigureAwait(false);
96+
return;
97+
}
98+
7899
if (!this.stoppedByLifecycle && !options.AlwaysStartOnForeground)
79100
return;
80101

@@ -87,6 +108,99 @@ async Task OnForegroundingAsync()
87108
this.stoppedByLifecycle = false;
88109
}
89110

111+
/// <summary>
112+
/// Remembers, on a platform that cannot keep a listener answering in the background, whether the
113+
/// server was running when the app went away - so the resume can put back what the suspension
114+
/// took.
115+
/// </summary>
116+
/// <remarks>
117+
/// <para>
118+
/// This is the Apple half of <see cref="BackgroundServerMode.KeepAlive"/>. Android has a
119+
/// foreground service and needs none of it; iOS has nothing that will hold a socket open, so the
120+
/// process is suspended, the listener stops answering, and the <see cref="HttpServer"/> object
121+
/// goes on reporting <see cref="HttpServer.IsRunning"/> because nothing on the platform tells it
122+
/// otherwise. Left alone, the user comes back to an app that says it is serving and a client
123+
/// that cannot connect - and no amount of pressing the toggle fixes it, because the toggle is
124+
/// already in the position it should be in.
125+
/// </para>
126+
/// <para>
127+
/// Recorded rather than inferred at resume: <see cref="HttpServer.IsRunning"/> is exactly the
128+
/// thing that has gone stale, so it cannot answer "was it on?". Only an explicit stop while
129+
/// backgrounded clears this - see <see cref="OnServerStateChanged"/> - so a server the app or
130+
/// the user switched off stays off. That restraint is exactly what
131+
/// <see cref="HttpServerLifecycleOptions.AlwaysStartOnForeground"/> exists to lift, and lifting
132+
/// it is a separate and more opinionated choice than restoring what was already on.
133+
/// </para>
134+
/// </remarks>
135+
void TrackForRestore(bool running)
136+
{
137+
if (SupportsBackgroundExecution)
138+
return;
139+
140+
this.restoreOnForeground = running;
141+
}
142+
143+
/// <summary>
144+
/// Keeps the background execution in step with the server itself.
145+
/// </summary>
146+
/// <remarks>
147+
/// <para>
148+
/// Without this the foreground service is decided once, at the moment the app is backgrounded,
149+
/// and never revisited - so it answers "was the server running when the user left?" rather than
150+
/// "is the server running now?". Those come apart in both directions, and both are wrong in a
151+
/// way the user sees:
152+
/// </para>
153+
/// <list type="bullet">
154+
/// <item>A server stopped while the app is in the background - by a toggle in a notification
155+
/// action, by the app's own code, by a rebind that failed - leaves the ongoing notification up,
156+
/// holding the process alive and telling the user something is being served when nothing is.</item>
157+
/// <item>A server started while the app is in the background gets no service at all, so Android
158+
/// reclaims the process within minutes and the listener dies with it. The app did everything
159+
/// right and the server simply stops.</item>
160+
/// </list>
161+
/// <para>
162+
/// Only the settled states act. <see cref="HttpServerState.Starting"/> and
163+
/// <see cref="HttpServerState.Stopping"/> deliberately leave it as it is: a bind that fails goes
164+
/// Starting then Stopped, and acting on the first would flash a notification for a server that
165+
/// never came up, while a stop waits in Stopping for in-flight requests - which is precisely
166+
/// when the process still needs holding up.
167+
/// </para>
168+
/// </remarks>
169+
void OnServerStateChanged(object? sender, HttpServerState state)
170+
{
171+
if (options.BackgroundMode != BackgroundServerMode.KeepAlive)
172+
return;
173+
174+
// Only while backgrounded. Null is "no transition yet", which is treated as the foreground:
175+
// of the two ways to be wrong before the app has ever been left, a missing notification is
176+
// recoverable at the next transition and an unasked-for one is just wrong.
177+
if (this.IsInForeground != false)
178+
return;
179+
180+
try
181+
{
182+
switch (state)
183+
{
184+
case HttpServerState.Running:
185+
this.StartBackgroundExecution();
186+
this.TrackForRestore(true);
187+
break;
188+
189+
case HttpServerState.Stopped:
190+
this.StopBackgroundExecution();
191+
this.TrackForRestore(false);
192+
break;
193+
}
194+
}
195+
catch (Exception ex)
196+
{
197+
// This runs inside the server's own state machine, on the thread that called Start or
198+
// Stop - so an exception here would surface out of StartAsync as though the server had
199+
// failed. The service is a platform concern and its failure is not the server's.
200+
logger.LogWarning(ex, "Failed to update background execution for the {State} server", state);
201+
}
202+
}
203+
90204
void OnConnectivityChanged(object? sender, EventArgs e) => _ = Task.Run(async () =>
91205
{
92206
try
@@ -108,6 +222,13 @@ void OnConnectivityChanged(object? sender, EventArgs e) => _ = Task.Run(async ()
108222
}
109223
});
110224

225+
/// <summary>
226+
/// Whether the platform can actually keep the listener answering with the app in the background.
227+
/// Android can, through a foreground service; Apple cannot, at any price - so the Apple build
228+
/// restores the server on resume instead. Everything that differs between the two hangs off this.
229+
/// </summary>
230+
static partial bool SupportsBackgroundExecution { get; }
231+
111232
/// <summary>Android starts a foreground service here; the Apple build has nothing to start.</summary>
112233
partial void StartBackgroundExecutionPlatform();
113234

@@ -125,6 +246,8 @@ public void Dispose()
125246
this.subscribed = false;
126247
}
127248

249+
server.StateChanged -= this.OnServerStateChanged;
250+
128251
GC.SuppressFinalize(this);
129252
}
130253
}

src/Shiny.Net.HttpServer.Mobile/Platforms/Android/HttpServerLifecycleTask.android.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ namespace Shiny.Net.HttpServer.Mobile;
44

55
public partial class HttpServerLifecycleTask
66
{
7+
/// <summary>A foreground service keeps the process and its listener alive, so nothing needs restoring on resume.</summary>
8+
static partial bool SupportsBackgroundExecution => true;
9+
710
partial void StartBackgroundExecutionPlatform()
811
{
912
if (HttpServerForegroundService.IsStarted)

src/Shiny.Net.HttpServer.Mobile/Platforms/Apple/HttpServerLifecycleTask.apple.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ namespace Shiny.Net.HttpServer.Mobile;
22

33
public partial class HttpServerLifecycleTask
44
{
5+
/// <summary>
6+
/// No, and there is no version of iOS where the answer changes.
7+
/// <para>
8+
/// So <see cref="BackgroundServerMode.KeepAlive"/> means something different here than it does
9+
/// on Android: not "keep it running", which is not on offer, but "put it back on resume". The
10+
/// server that was serving when the user switched away is serving again by the time they are
11+
/// looking at the app, without the app having to notice the suspension or hold the flag itself.
12+
/// </para>
13+
/// </summary>
14+
static partial bool SupportsBackgroundExecution => false;
15+
516
/// <summary>
617
/// Nothing to start.
718
/// <para>

version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"assemblyVersion": {
55
"precision": "revision"
66
},

0 commit comments

Comments
 (0)