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); }