Public Access
Announce a session's end when it is actually over, and for closes too
The phone's notification kept saying '1 shell connected' after the shell was gone, and both close routes were at fault. A shell exiting on its own raised SessionEnded from inside its run's finally block — where the run task is by definition not yet complete, so the LiveSessionCount the keep-alive reads still counted the dead shell, and nothing fired later to correct it. A tab closed by hand announced nothing at all, by a recorded decision that assumed every subscriber was the closer; the keep-alive is not, and a close it never heard about left the notification claiming a shell over nothing. The end is now announced from a continuation after the run completes, and CloseSessionAsync announces after its own drain — every subscriber was already a reconcile-to-reality handler, so the echo the old remark feared costs nothing. Shutdown stays silent: it is dismantling the subscribers along with the sessions.
This commit is contained in:
@@ -257,7 +257,8 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised with the session id when a shell ends on its own.
|
||||
/// Raised with the session id once a session is over — its shell having ended on its own, or a
|
||||
/// deliberate close having fully drained.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -268,13 +269,23 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// the half of the interface Avalonia draws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Raised on whatever thread the pump finished on</b>, which is a thread-pool thread. A handler that
|
||||
/// touches an observable collection has to marshal; this type has no toolkit to do it with, which is
|
||||
/// exactly why it does not try.
|
||||
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
|
||||
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
|
||||
/// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
|
||||
/// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
|
||||
/// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
|
||||
/// closer's own thread; a handler that touches an observable collection has to marshal either way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not raised by <see cref="CloseSessionAsync"/>. That path already has a caller who knows the session is
|
||||
/// going, and telling it what it just asked for is how a tab close turns into a second tab close.
|
||||
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
|
||||
/// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
|
||||
/// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
|
||||
/// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
|
||||
/// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
|
||||
/// skipped if it is not — so a second announcement for a session that already announced its own end
|
||||
/// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
|
||||
/// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
|
||||
/// sessions, and news nobody is left to hear is not news.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
||||
@@ -403,6 +414,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
sessions[sessionId] = new LiveSession(connection, pump, run);
|
||||
}
|
||||
|
||||
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
||||
// so the containment check inside it can never run against a dictionary the session had not reached.
|
||||
_ = AnnounceEndedAsync(sessionId, run);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@@ -574,6 +589,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
{
|
||||
// Expected on the ordinary path: disposing the pump cancels its run.
|
||||
}
|
||||
|
||||
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
||||
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
||||
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
||||
if (Volatile.Read(ref disposed) == 0)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -694,21 +717,51 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||
// the application is closing.
|
||||
//
|
||||
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
||||
// AnnounceEndedAsync for what was wrong with that.
|
||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
|
||||
// entry before it disposes the pump, so a tab the user closed does not come back as news.
|
||||
bool announce;
|
||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
||||
/// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
|
||||
/// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
|
||||
/// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
|
||||
/// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
|
||||
/// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
|
||||
/// complete and the count is honest.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
|
||||
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AnnounceEndedAsync(uint sessionId, Task run)
|
||||
{
|
||||
try
|
||||
{
|
||||
await run.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
|
||||
// This continuation cares only that the run is over, however it got there.
|
||||
}
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
bool announce;
|
||||
|
||||
if (announce)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
lock (sessionGate)
|
||||
{
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
if (announce)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user