using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Session;
/// A connection that has started and has no log entry yet, because it has not ended.
/// What the host is called.
/// The address as dialled.
/// When it opened.
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
///
/// Records connections into whichever vault is open, without ever making the caller wait.
///
///
///
/// A process-lifetime object with session-scoped contents, exactly like
/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
/// so a recorder created per session would have to be threaded through an object that must not know about
/// vaults at all. on unlock, on lock.
///
///
/// Nothing on the calling thread does any work. Both interface methods take a lock, touch a
/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
/// writing. That is not tidiness — Closed is called from a finally unwinding on a thread-pool
/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
/// exactly how closing an application comes to take four seconds.
///
///
/// A shell can outlive the vault, so close-out has to as well. A tab opened before a lock and closed
/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
/// it was opened against rather than reading whichever one is current. The write then fails if the session
/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
/// never worth surfacing an error over.
///
///
/// The queue is bounded and drops the oldest when full. An unbounded one would turn a stuck write into
/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
/// the newest — which is what somebody reading a log actually wants.
///
///
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
{
///
/// How many close-outs may be waiting to be written.
///
///
/// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
/// stopped draining — which is the case it exists for.
///
private const int QueueDepth = 256;
/// How long waits for the queue to be written.
///
/// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
/// write — and short enough that a stuck cache cannot become a window that will not close.
///
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel pending = Channel.CreateBounded(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly Dictionary tickets = [];
private readonly Lock gate = new();
private readonly TimeProvider clock;
private readonly string deviceName;
private readonly Task drain;
private readonly CancellationTokenSource lifetime = new();
private Binding? binding;
private int disposed;
/// Time source. Used only for a duration this type did not receive.
/// What this machine calls itself, recorded on every entry.
public ConnectionRecorder(TimeProvider clock, string deviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
this.clock = clock;
this.deviceName = deviceName;
drain = DrainAsync(lifetime.Token);
}
///
/// The connections that have opened and not yet been recorded.
///
///
/// For the logs screen, which shows these above the finished entries. It reads them from here rather
/// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
/// on that screen appears and disappears in step with the entry that will replace it, rather than in
/// step with a tab, which is a different thing that merely usually agrees.
///
public IReadOnlyList Open()
{
lock (gate)
{
return
[
.. tickets.Values
.Select(ticket => new OpenConnection(
ticket.HostLabel, ticket.Address, ticket.StartedAt))
.OrderByDescending(open => open.StartedAt),
];
}
}
/// Whether a vault is open behind this recorder.
public bool IsOpen
{
get
{
lock (gate)
{
return binding is not null;
}
}
}
/// Starts recording into an unlocked vault.
/// The unlocked session. Its active vault is the one written to.
/// Which account this is, recorded on every entry.
public void Open(VaultSession session, Guid actorUserId)
{
ArgumentNullException.ThrowIfNull(session);
lock (gate)
{
binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
}
}
///
/// Stops recording new connections.
///
///
/// Open tickets are deliberately not discarded. Each already holds the repository it was opened
/// against, so a shell still running when the vault locks closes out into the vault it was made from —
/// which is the honest record. What is dropped is the ability to start a ticket, because a
/// connection made while locked has no vault to belong to.
///
public void Close()
{
lock (gate)
{
binding = null;
}
}
///
public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
lock (gate)
{
if (binding is not { } open)
{
return;
}
// The address stands in for the name until Identify supplies one, so a connection made by
// something that never calls it is still recorded — with a worse label, which beats no entry.
tickets[sessionId] = new OpenTicket(
open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
}
}
///
/// Names the host an already-open session belongs to.
///
/// The session, as the workspace knows it.
/// What the host is called in the keychain.
/// The host item.
///
///
/// The workspace takes an SshConnectionRequest, which has no notion of a keychain item, so it
/// knows an address and nothing else. The label and the id arrive here instead, from the view model that
/// does know — and as an amendment rather than a second ticket, so the start time stays the one the
/// workspace recorded rather than the slightly later one this call would carry.
///
///
/// A session id with no ticket is ignored, which is what a connection made while the vault was locked
/// looks like.
///
///
public void Identify(uint sessionId, string hostLabel, Guid? hostId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
lock (gate)
{
if (tickets.TryGetValue(sessionId, out var ticket))
{
tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
}
}
}
///
public void Closed(uint sessionId, DateTimeOffset endedAt)
{
OpenTicket ticket;
lock (gate)
{
if (!tickets.Remove(sessionId, out var found))
{
// Never opened, already closed, or opened while the vault was locked. All three mean there
// is nothing to record, and none of them is an error.
return;
}
ticket = found;
}
Queue(ticket, endedAt, ConnectionOutcome.Closed);
}
///
/// Records a connection that was never a workspace session.
///
/// The address that was dialled.
/// What the host is called.
/// The host item, if there was one.
/// Which sort of session it was.
/// When it began.
/// When it ended, which is the same instant for an attempt that failed.
/// How it ended.
///
///
/// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
/// a connection that never opened — the workspace throws out of ConnectAsync before an id exists,
/// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
/// entirely and would collide with a terminal's id if it borrowed one.
///
///
/// A run of refusals against one host is the single most interesting thing a connection log can show,
/// which is why the failures are recorded at all rather than only the sessions that worked.
///
///
public void Record(
string address,
string hostLabel,
Guid? hostId,
ConnectionKind kind,
DateTimeOffset startedAt,
DateTimeOffset endedAt,
ConnectionOutcome outcome)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
Binding open;
lock (gate)
{
if (binding is not { } current)
{
return;
}
open = current;
}
Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
}
///
/// Closes out every still-open connection and writes what is queued, within a bounded wait.
///
///
///
/// Closing the application is the ordinary way a session ends, and without this every one of them
/// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
/// the vault they would be written into has gone. So the tickets are closed here instead, while there is
/// still something to write to, and the durations run to the moment of exit — which is what actually
/// happened.
///
///
/// The wait is bounded and the remainder is dropped. An advisory log is never worth making a
/// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
/// patience.
///
///
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
OpenTicket[] remaining;
lock (gate)
{
remaining = [.. tickets.Values];
tickets.Clear();
binding = null;
}
var at = clock.GetUtcNow();
foreach (var ticket in remaining)
{
Queue(ticket, at, ConnectionOutcome.Closed);
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
{
// A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
// but a machine that resumed from sleep between them can still produce a negative one, and the
// payload refuses those outright.
var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;
var entry = new ConnectionLogSecret
{
HostLabel = ticket.HostLabel,
Address = ticket.Address,
HostId = ticket.HostId,
Kind = ticket.Kind,
StartedAt = ticket.StartedAt,
Duration = duration,
Outcome = outcome,
DeviceName = deviceName,
ActorUserId = ticket.Binding.ActorUserId,
};
// TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
// a bounded channel with DropOldest never refuses anyway.
pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await item.Binding.Log
.CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
// there is no caller left to tell. The realistic failures are a session disposed between
// the queue and the write — a shell closed after the vault locked — and a cache that has
// gone away underneath it. Neither is worth an unobserved exception on a background task.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
/// Which vault entries go to, and who is making them.
private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
/// A connection that has started and not yet been recorded.
///
/// It carries its own rather than reading the current one at close time, which is
/// what lets a session outlive the vault it was opened in without being filed into the next one.
///
private sealed record OpenTicket(
Binding Binding,
string Address,
string HostLabel,
Guid? HostId,
ConnectionKind Kind,
DateTimeOffset StartedAt);
private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
}