diff --git a/pwiz_tools/Skyline/Controls/ControlsResources.designer.cs b/pwiz_tools/Skyline/Controls/ControlsResources.designer.cs index f15dcb296f7..9939fce47e1 100644 --- a/pwiz_tools/Skyline/Controls/ControlsResources.designer.cs +++ b/pwiz_tools/Skyline/Controls/ControlsResources.designer.cs @@ -123,6 +123,15 @@ public static string LongWaitDlg_PerformWork_canceled { } } + /// + /// Looks up a localized string similar to Stopping. + /// + public static string RunningJobsDlg_GetProgressText_Stopping { + get { + return ResourceManager.GetString("RunningJobsDlg_GetProgressText_Stopping", resourceCulture); + } + } + /// /// Looks up a localized string similar to Field. /// diff --git a/pwiz_tools/Skyline/Controls/ControlsResources.resx b/pwiz_tools/Skyline/Controls/ControlsResources.resx index ed6c7ec5d74..4ae954f757d 100644 --- a/pwiz_tools/Skyline/Controls/ControlsResources.resx +++ b/pwiz_tools/Skyline/Controls/ControlsResources.resx @@ -139,6 +139,9 @@ canceled + + Stopping + Field diff --git a/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.designer.cs b/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.designer.cs index 1f268d52e02..c549a66ab72 100644 --- a/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.designer.cs +++ b/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.designer.cs @@ -179,6 +179,15 @@ public static string ExportLiveReportDlg_ExportLiveReportDlg_Invariant { } } + /// + /// Looks up a localized string similar to Exporting report '{0}'. + /// + public static string ExportLiveReportDlg_ExportReport_Exporting_report___0__ { + get { + return ResourceManager.GetString("ExportLiveReportDlg_ExportReport_Exporting_report___0__", resourceCulture); + } + } + /// /// Looks up a localized string similar to Preview: . /// diff --git a/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.resx b/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.resx index 1d151f3dfc9..ea970b854b5 100644 --- a/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.resx +++ b/pwiz_tools/Skyline/Controls/Databinding/DatabindingResources.resx @@ -157,6 +157,9 @@ Invariant + + Exporting report '{0}' + Preview: diff --git a/pwiz_tools/Skyline/Controls/Databinding/ExportLiveReportDlg.cs b/pwiz_tools/Skyline/Controls/Databinding/ExportLiveReportDlg.cs index 245138d0f19..d49e747b101 100644 --- a/pwiz_tools/Skyline/Controls/Databinding/ExportLiveReportDlg.cs +++ b/pwiz_tools/Skyline/Controls/Databinding/ExportLiveReportDlg.cs @@ -158,9 +158,12 @@ private bool ExportReport(string filename, IReportExporter rowItemExporter) } try { - using var fileSaver = new FileSaver(filename, true); + // Not a using: the file is written by the WORK below, which owns the saver from the moment it + // starts. Once the user sends the export to the background there is no caller left to commit it. + var fileSaver = new FileSaver(filename, true); if (!fileSaver.CanSave(this)) { + fileSaver.Dispose(); return false; } @@ -168,20 +171,29 @@ private bool ExportReport(string filename, IReportExporter rowItemExporter) longWaitDlg.Text = DatabindingResources.ExportReportDlg_ExportReport_Generating_Report; IProgressStatus status = new ProgressStatus(DatabindingResources.ExportReportDlg_ExportReport_Building_report); var dataSchema = GetSkylineDataSchema(true); + // StartJob, not PerformWork: the user may leave this export running in the background. It qualifies + // because it writes only its own file and reads a snapshot of the document (GetSkylineDataSchema + // clones it), so nothing it does depends on what the user does next. + var jobDescription = string.Format( + DatabindingResources.ExportLiveReportDlg_ExportReport_Exporting_report___0__, viewName.Value.Name); // ReSharper disable once RedundantLambdaParameterType - longWaitDlg.PerformWork(this, 1500, (IProgressMonitor progressMonitor) => + var outcome = longWaitDlg.StartJob(this, 1500, jobDescription, (IProgressMonitor progressMonitor) => { - progressMonitor.UpdateProgress(status); - var rowFactories = RowFactories.GetRowFactories(longWaitDlg.CancellationToken, dataSchema); - rowFactories.ExportReport(fileSaver.Stream, viewName.Value, rowItemExporter, progressMonitor, ref status); + using (fileSaver) + { + progressMonitor.UpdateProgress(status); + var rowFactories = RowFactories.GetRowFactories(longWaitDlg.CancellationToken, dataSchema); + rowFactories.ExportReport(fileSaver.Stream, viewName.Value, rowItemExporter, progressMonitor, ref status); + // A cancelled export stops part way through the file, so it must not be committed. The + // saver discards what it wrote as it is disposed. + if (!progressMonitor.IsCanceled) + { + fileSaver.Commit(); + } + } }); - if (longWaitDlg.IsCanceled) - { - return false; - } - - fileSaver.Commit(); - return true; + // Backgrounding is not cancelling: the export is running, and this dialog is done with it. + return outcome != LongWaitDlg.JobOutcome.canceled; } catch (Exception x) { diff --git a/pwiz_tools/Skyline/Controls/LongWaitDlg.Designer.cs b/pwiz_tools/Skyline/Controls/LongWaitDlg.Designer.cs index 55ade8e93a2..76842d579d2 100644 --- a/pwiz_tools/Skyline/Controls/LongWaitDlg.Designer.cs +++ b/pwiz_tools/Skyline/Controls/LongWaitDlg.Designer.cs @@ -31,6 +31,7 @@ private void InitializeComponent() this.components = new System.ComponentModel.Container(); System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LongWaitDlg)); this.btnCancel = new System.Windows.Forms.Button(); + this.btnBackground = new System.Windows.Forms.Button(); this.progressBar = new System.Windows.Forms.ProgressBar(); this.labelMessage = new System.Windows.Forms.Label(); this.timerUpdate = new System.Windows.Forms.Timer(this.components); @@ -46,7 +47,14 @@ private void InitializeComponent() this.btnCancel.Name = "btnCancel"; this.btnCancel.UseVisualStyleBackColor = true; this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); - // + // + // btnBackground + // + resources.ApplyResources(this.btnBackground, "btnBackground"); + this.btnBackground.Name = "btnBackground"; + this.btnBackground.UseVisualStyleBackColor = true; + this.btnBackground.Click += new System.EventHandler(this.btnBackground_Click); + // // progressBar // resources.ApplyResources(this.progressBar, "progressBar"); @@ -80,6 +88,7 @@ private void InitializeComponent() this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.CancelButton = this.btnCancel; this.Controls.Add(this.progressBar); + this.Controls.Add(this.btnBackground); this.Controls.Add(this.btnCancel); this.Controls.Add(this.panel1); this.MaximizeBox = false; @@ -95,6 +104,7 @@ private void InitializeComponent() #endregion private System.Windows.Forms.Button btnCancel; + private System.Windows.Forms.Button btnBackground; private System.Windows.Forms.ProgressBar progressBar; private System.Windows.Forms.Label labelMessage; private System.Windows.Forms.Timer timerUpdate; diff --git a/pwiz_tools/Skyline/Controls/LongWaitDlg.cs b/pwiz_tools/Skyline/Controls/LongWaitDlg.cs index 303d2cd7f5f..5c38a7aa3c1 100644 --- a/pwiz_tools/Skyline/Controls/LongWaitDlg.cs +++ b/pwiz_tools/Skyline/Controls/LongWaitDlg.cs @@ -49,11 +49,16 @@ public partial class LongWaitDlg : FormEx, ILongWaitBroker, ILongWaitForm private readonly CancellationTokenSource _cancellationTokenSource; private ManualResetEvent _completionEvent; + // The job the work was handed to when the user pressed "Run in Background". Set on the UI thread and read + // on the work's thread, which goes on reporting progress to it long after this dialog is gone. + private volatile BackgroundJob _backgroundJob; + // these members should only be accessed in a block which locks on _lock #region synchronized members private readonly object _lock = new object(); private bool _finished; private bool _windowShown; + private bool _backgrounded; #endregion /// @@ -82,7 +87,11 @@ public LongWaitDlg(IDocumentContainer documentContainer = null, bool allowCancel public string Message { get { return _message; } - set { _message = value; } + set + { + _message = value; + ReportBackgroundJobProgress(); + } } public int ProgressValue @@ -92,9 +101,23 @@ public int ProgressValue { Assume.IsTrue(value <= 100); _progressValue = value; + ReportBackgroundJobProgress(); } } + /// What became of the work ran. + public enum JobOutcome + { + completed, + canceled, + /// The user sent it to the background; it is still running, as a job. + backgrounded + } + + // What the work is called once it is running in the background. Set by StartJob only, which is what makes + // the button - and backgrounding at all - something a caller opts into. + private string _jobDescription; + public override string DetailedMessage { get { return string.Format(@"[{0}] {1} ({2}%)", Text, Message, ProgressValue); } @@ -152,6 +175,39 @@ public IProgressStatus PerformWork(Control parent, int delayMillis, [InstantHand return progressWaitBroker.Status; } + /// + /// Runs work that the user may send to the background, and reports what became of it. The same as + /// except that this dialog offers a "Run in + /// Background" button: pressing it closes the dialog and returns from here with + /// , leaving the work running as a job that reports to the status bar + /// and can be cancelled there (Tools > Running Jobs). + /// + /// It is a separate method, and not an option on PerformWork, because the work has to be written for + /// it. The delegate OUTLIVES this call, so it must be SELF-CONTAINED: everything that finishes the + /// operation - committing a file, reporting a result - has to happen inside it, since the caller resumes + /// the moment the button is pressed and there is nobody left to do it afterwards. Work that applies its + /// result to the document when it lands may not be run this way at all - the user goes on editing while it + /// runs, and it would finish by overwriting what they did - nor may work that asks the user something part + /// way through (the ShowDialog below), which has no window left to show it in once this one has closed. + /// Everything else keeps calling PerformWork, whose delegate is always finished with by the time it + /// returns. + /// + /// The window the dialog belongs to. + /// How long to let the work run before showing the dialog at all. + /// What the job is called in the status bar and the job list once it is + /// backgrounded ("Exporting report 'Peak Areas'"). + /// The work. Must be self-contained - see above. + public JobOutcome StartJob(Control parent, int delayMillis, string jobDescription, + Action performWork) + { + _jobDescription = jobDescription; + var progressWaitBroker = new ProgressWaitBroker(performWork); + PerformWork(parent, delayMillis, progressWaitBroker.PerformWork); + if (_backgroundJob != null) + return JobOutcome.backgrounded; + return IsCanceled ? JobOutcome.canceled : JobOutcome.completed; + } + public void PerformWork(Control parent, int delayMillis, [InstantHandle] Action performWork) { _startTime = DateTime.UtcNow; // Said to be 117x faster than Now and this is for a delta @@ -180,6 +236,7 @@ public void PerformWork(Control parent, int delayMillis, [InstantHandle] Action< progressBar.Value = Math.Max(0, _progressValue); UpdateLabelMessage(); + btnBackground.Visible = _jobDescription != null; ShowDialog(parent); } @@ -248,9 +305,9 @@ protected override void OnFormClosing(FormClosingEventArgs e) { lock (_lock) { - if (!_finished) + if (!_finished && !_backgrounded) { - // If the user is trying to close this form, then treat it the + // If the user is trying to close this form, then treat it the // same as if they had hit "Cancel". OnClickedCancel(); e.Cancel = true; @@ -294,9 +351,31 @@ private void RunWork(Action performWork) { _completionEvent?.Set(); } + + FinishBackgroundJob(); } } + /// + /// Ends the job this work was sent to, if it was sent to one. This is where a backgrounded operation + /// reports what became of it: returned when + /// the user pressed the button, so the caller that would have been thrown the exception, or seen the + /// operation through, is long gone. + /// + private void FinishBackgroundJob() + { + var job = _backgroundJob; + if (job == null) + return; + + // A cancel is not a failure to report - the same rule PerformWork applies to what it rethrows. + if (_exception != null && !(IsCanceled && _exception.HasException())) + { + job.Failed(_exception); + } + job.Dispose(); + } + private void FinishDialog() { if (!_cancellationTokenSource.IsCancellationRequested) @@ -334,6 +413,41 @@ private void btnCancel_Click(object sender, EventArgs e) OnClickedCancel(); } + private void btnBackground_Click(object sender, EventArgs e) + { + RunInBackground(); + } + + /// + /// Hands the running work to a and closes this dialog, which returns + /// to its caller while the work goes on. From here the job reports its progress to + /// the main window's status bar, and cancelling the job is what stops it - it is registered to trip this + /// dialog's own cancellation, so the work goes on watching the one token it always has. + /// + public void RunInBackground() + { + lock (_lock) + { + // Nothing to hand over: the work either finished while the user was reaching for the button, or + // was never started by StartJob, the only caller that says what a job of it would be called. + if (_finished || _backgrounded || _jobDescription == null) + return; + _backgroundJob = BackgroundJobs.Start(_jobDescription); + _backgrounded = true; + } + _backgroundJob.CancellationToken.Register(() => _cancellationTokenSource.Cancel()); + ReportBackgroundJobProgress(); + Close(); + } + + // Once the work is in the background its progress belongs to the status bar and the job list, not to this + // dialog, which is gone. Every report the work makes passes through Message and ProgressValue, so this is + // the one place that has to forward. + private void ReportBackgroundJobProgress() + { + _backgroundJob?.UpdateProgress(_message, _progressValue); + } + private void OnClickedCancel() { if (!_cancellationTokenSource.IsCancellationRequested) diff --git a/pwiz_tools/Skyline/Controls/LongWaitDlg.resx b/pwiz_tools/Skyline/Controls/LongWaitDlg.resx index 33c8b218245..3bd36c60a2b 100644 --- a/pwiz_tools/Skyline/Controls/LongWaitDlg.resx +++ b/pwiz_tools/Skyline/Controls/LongWaitDlg.resx @@ -148,6 +148,36 @@ $this + 2 + + + Bottom, Right + + + 144, 102 + + + 115, 23 + + + 4 + + + Run in Background + + + False + + + btnBackground + + + System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + $this + + 1 @@ -235,7 +265,7 @@ $this - 2 + 3 True diff --git a/pwiz_tools/Skyline/Controls/RunningJobsDlg.Designer.cs b/pwiz_tools/Skyline/Controls/RunningJobsDlg.Designer.cs new file mode 100644 index 00000000000..b9f97877d37 --- /dev/null +++ b/pwiz_tools/Skyline/Controls/RunningJobsDlg.Designer.cs @@ -0,0 +1,115 @@ +namespace pwiz.Skyline.Controls +{ + partial class RunningJobsDlg + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(RunningJobsDlg)); + this.listJobs = new System.Windows.Forms.ListView(); + this.colDescription = new System.Windows.Forms.ColumnHeader(); + this.colMessage = new System.Windows.Forms.ColumnHeader(); + this.colProgress = new System.Windows.Forms.ColumnHeader(); + this.btnCancelJob = new System.Windows.Forms.Button(); + this.btnClose = new System.Windows.Forms.Button(); + this.timerRefresh = new System.Windows.Forms.Timer(this.components); + this.SuspendLayout(); + // + // listJobs + // + resources.ApplyResources(this.listJobs, "listJobs"); + this.listJobs.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { + this.colDescription, + this.colMessage, + this.colProgress}); + this.listJobs.FullRowSelect = true; + this.listJobs.HideSelection = false; + this.listJobs.MultiSelect = false; + this.listJobs.Name = "listJobs"; + this.listJobs.UseCompatibleStateImageBehavior = false; + this.listJobs.View = System.Windows.Forms.View.Details; + this.listJobs.SelectedIndexChanged += new System.EventHandler(this.listJobs_SelectedIndexChanged); + // + // colDescription + // + resources.ApplyResources(this.colDescription, "colDescription"); + // + // colMessage + // + resources.ApplyResources(this.colMessage, "colMessage"); + // + // colProgress + // + resources.ApplyResources(this.colProgress, "colProgress"); + // + // btnCancelJob + // + resources.ApplyResources(this.btnCancelJob, "btnCancelJob"); + this.btnCancelJob.Name = "btnCancelJob"; + this.btnCancelJob.UseVisualStyleBackColor = true; + this.btnCancelJob.Click += new System.EventHandler(this.btnCancelJob_Click); + // + // btnClose + // + resources.ApplyResources(this.btnClose, "btnClose"); + this.btnClose.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.btnClose.Name = "btnClose"; + this.btnClose.UseVisualStyleBackColor = true; + // + // timerRefresh + // + this.timerRefresh.Enabled = true; + this.timerRefresh.Interval = 500; + this.timerRefresh.Tick += new System.EventHandler(this.timerRefresh_Tick); + // + // RunningJobsDlg + // + resources.ApplyResources(this, "$this"); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.btnClose; + this.Controls.Add(this.listJobs); + this.Controls.Add(this.btnCancelJob); + this.Controls.Add(this.btnClose); + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "RunningJobsDlg"; + this.ShowInTaskbar = false; + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.ListView listJobs; + private System.Windows.Forms.ColumnHeader colDescription; + private System.Windows.Forms.ColumnHeader colMessage; + private System.Windows.Forms.ColumnHeader colProgress; + private System.Windows.Forms.Button btnCancelJob; + private System.Windows.Forms.Button btnClose; + private System.Windows.Forms.Timer timerRefresh; + } +} diff --git a/pwiz_tools/Skyline/Controls/RunningJobsDlg.cs b/pwiz_tools/Skyline/Controls/RunningJobsDlg.cs new file mode 100644 index 00000000000..1e05614be3d --- /dev/null +++ b/pwiz_tools/Skyline/Controls/RunningJobsDlg.cs @@ -0,0 +1,154 @@ +/* + * Original author: Nicholas Shulman , + * MacCoss Lab, Department of Genome Sciences, UW + * AI assistance: Claude Code (Claude Opus 5) + * + * Copyright 2026 University of Washington - Seattle, WA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; +using System.Linq; +using System.Windows.Forms; +using pwiz.Skyline.Properties; +using pwiz.Skyline.Util; + +namespace pwiz.Skyline.Controls +{ + /// + /// Shows what is running in the background and lets the user stop it: the operations they sent there with a + /// LongWaitDlg's "Run in Background", and the ones a tool left running when it stopped waiting for them. + /// Reached from Tools > Running Jobs, or by double-clicking the status bar where their progress shows. + /// + public partial class RunningJobsDlg : FormEx + { + public RunningJobsDlg() + { + InitializeComponent(); + Icon = Resources.Skyline; + RefreshJobs(); + } + + /// + /// Puts what is running into the list. Rows are matched to jobs by id and updated in place, so the row the + /// user has selected stays selected (and stays theirs to cancel) while its progress advances underneath. + /// + private void RefreshJobs() + { + var jobs = BackgroundJobs.Running; + listJobs.BeginUpdate(); + try + { + // Drop the rows of jobs that have finished. + foreach (var item in listJobs.Items.Cast().ToArray()) + { + if (jobs.All(job => !Equals(job.JobId, item.Tag))) + { + listJobs.Items.Remove(item); + } + } + + foreach (var job in jobs) + { + var item = listJobs.Items.Cast().FirstOrDefault(row => Equals(job.JobId, row.Tag)); + if (item == null) + { + item = new ListViewItem(job.Description) { Tag = job.JobId }; + item.SubItems.Add(string.Empty); + item.SubItems.Add(string.Empty); + listJobs.Items.Add(item); + } + else + { + item.Text = job.Description; + } + item.SubItems[1].Text = job.Message ?? string.Empty; + item.SubItems[2].Text = GetProgressText(job); + } + + // Keep a job selected, so Cancel Job always has an obvious target - most of the time there is only + // the one, and the user came here to stop it. + if (listJobs.SelectedItems.Count == 0 && listJobs.Items.Count > 0) + { + listJobs.Items[0].Selected = true; + } + } + finally + { + listJobs.EndUpdate(); + } + UpdateButtons(); + } + + // A percentage, except while the job cannot say how far along it is (-1, which shows as a marquee in the + // status bar), or once it has been asked to stop and is finishing what it was doing. + private string GetProgressText(JobProgressStatus job) + { + if (BackgroundJobs.IsCancelRequested(job.JobId)) + return ControlsResources.RunningJobsDlg_GetProgressText_Stopping; + if (job.PercentComplete < 0) + return string.Empty; + return job.PercentComplete.ToString(@"0'%'", CultureInfo.CurrentCulture); + } + + private void UpdateButtons() + { + btnCancelJob.Enabled = SelectedJobId.HasValue; + } + + public Guid? SelectedJobId + { + get + { + var item = listJobs.SelectedItems.Cast().FirstOrDefault(); + return (Guid?) item?.Tag; + } + } + + /// + /// Asks the selected job to stop. It stops at its next cancellation check, so the row stays until it has - + /// reading "Stopping" in the meantime. Called Stop in the UI, where "Cancel" would be read as cancelling + /// the dialog; it is cancellation underneath, which is all a job can be asked for. + /// + public void CancelSelectedJob() + { + var jobId = SelectedJobId; + if (jobId.HasValue) + { + BackgroundJobs.Cancel(jobId.Value); + } + RefreshJobs(); + } + + public int JobCount + { + get { return listJobs.Items.Count; } + } + + private void timerRefresh_Tick(object sender, EventArgs e) + { + RefreshJobs(); + } + + private void listJobs_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateButtons(); + } + + private void btnCancelJob_Click(object sender, EventArgs e) + { + CancelSelectedJob(); + } + } +} diff --git a/pwiz_tools/Skyline/Controls/RunningJobsDlg.resx b/pwiz_tools/Skyline/Controls/RunningJobsDlg.resx new file mode 100644 index 00000000000..4fec6880cd5 --- /dev/null +++ b/pwiz_tools/Skyline/Controls/RunningJobsDlg.resx @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Top, Bottom, Left, Right + + + + 12, 12 + + + 460, 180 + + + + 0 + + + listJobs + + + System.Windows.Forms.ListView, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + $this + + + 0 + + + Job + + + 200 + + + Status + + + 180 + + + Progress + + + 70 + + + Bottom, Left + + + 12, 202 + + + 90, 23 + + + 1 + + + Stop Job + + + btnCancelJob + + + System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + $this + + + 1 + + + Bottom, Right + + + 397, 202 + + + 75, 23 + + + 2 + + + Close + + + btnClose + + + System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + $this + + + 2 + + + 155, 17 + + + True + + + 6, 13 + + + 484, 237 + + + CenterParent + + + Running Jobs + + + RunningJobsDlg + + + pwiz.Skyline.Util.FormEx, Skyline, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null + + diff --git a/pwiz_tools/Skyline/Documentation/Help/en/KeyboardShortcuts.html b/pwiz_tools/Skyline/Documentation/Help/en/KeyboardShortcuts.html index cf68d1ad31b..f5b908c2caf 100644 --- a/pwiz_tools/Skyline/Documentation/Help/en/KeyboardShortcuts.html +++ b/pwiz_tools/Skyline/Documentation/Help/en/KeyboardShortcuts.html @@ -311,6 +311,7 @@

Menu Mnemonics

Tools → External ToolsAlt+T,E Tools → Search ToolsAlt+T,S Tools → Immediate WindowAlt+T,I +Tools → Running JobsAlt+T,R Tools → OptionsAlt+T,O Help → HomeAlt+H,H Help → VideosAlt+H,V diff --git a/pwiz_tools/Skyline/Documentation/Help/ja/KeyboardShortcuts.html b/pwiz_tools/Skyline/Documentation/Help/ja/KeyboardShortcuts.html index 6e5806dafc8..46232fb7d13 100644 --- a/pwiz_tools/Skyline/Documentation/Help/ja/KeyboardShortcuts.html +++ b/pwiz_tools/Skyline/Documentation/Help/ja/KeyboardShortcuts.html @@ -311,6 +311,7 @@

メニューアクセスキー

ツール(T) → 外部ツール(E)Alt+T,E ツール(T) → 検索ツール(S)Alt+T,S ツール(T) → イミディエイトウィンドウ(I)Alt+T,I +ツール(T) → Running JobsAlt+T,R ツール(T) → オプション(O)Alt+T,O ヘルプ(H) → ホーム(H)Alt+H,H ヘルプ(H) → 動画(V)Alt+H,V diff --git a/pwiz_tools/Skyline/Documentation/Help/zh-CHS/KeyboardShortcuts.html b/pwiz_tools/Skyline/Documentation/Help/zh-CHS/KeyboardShortcuts.html index b0f832c4037..b1c2eebad45 100644 --- a/pwiz_tools/Skyline/Documentation/Help/zh-CHS/KeyboardShortcuts.html +++ b/pwiz_tools/Skyline/Documentation/Help/zh-CHS/KeyboardShortcuts.html @@ -310,6 +310,7 @@

菜单助记符

工具(T) → 外部工具(E)Alt+T,E 工具(T) → 搜索工具(S)Alt+T,S 工具(T) → 即时窗口(I)Alt+T,I +工具(T) → Running JobsAlt+T,R 工具(T) → 选项(O)Alt+T,O 帮助(H) → 主页(H)Alt+H,H 帮助(H) → 视频(V)Alt+H,V diff --git a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineAiConnector/SkylineAiConnector.zip b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineAiConnector/SkylineAiConnector.zip index f24c09a86ba..6d91b0efca2 100644 Binary files a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineAiConnector/SkylineAiConnector.zip and b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineAiConnector/SkylineAiConnector.zip differ diff --git a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/SkylineConnection.cs b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/SkylineConnection.cs index 55f9895a2e0..d3fb02b53d4 100644 --- a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/SkylineConnection.cs +++ b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/SkylineConnection.cs @@ -82,6 +82,8 @@ private SkylineConnection(SkylineJsonToolClient client) public TutorialListItem[] GetAvailableTutorials() { return CallClient(c => c.GetAvailableTutorials()); } public ReportDocTopicSummary[] GetReportDocTopics(string dataSource = null) { return CallClient(c => c.GetReportDocTopics(dataSource)); } public string GetProcessId() { return CallClient(c => c.GetProcessId()); } + public JobInfo[] GetRunningJobs() { return CallClient(c => c.GetRunningJobs()); } + public ActionResult CancelJob(string jobId) { return CallClient(c => c.CancelJob(jobId)); } public int ModalNestingCount() { return CallClient(c => c.ModalNestingCount()); } public FormInfo[] GetOpenForms() { return CallClient(c => c.GetOpenForms()); } public ControlInfo[] GetControls(string formId) { return CallClient(c => c.GetControls(formId)); } diff --git a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/Tools/SkylineTools.cs b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/Tools/SkylineTools.cs index c1d0d4d2498..b43a80bcc90 100644 --- a/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/Tools/SkylineTools.cs +++ b/pwiz_tools/Skyline/Executables/Tools/SkylineMcp/SkylineMcpServer/Tools/SkylineTools.cs @@ -1309,6 +1309,50 @@ public static string SetUiMode( }); } + [McpServerTool(Name = "skyline_get_running_jobs"), + Description("List the operations THIS connection started that Skyline is still working on. " + + "A call that timed out keeps running as a job, so this is how to see whether it is still going, " + + "how far along it is, and get the id needed to stop it with skyline_cancel_job. " + + "Work the user started (importing results, building a library) is not listed and cannot be cancelled here.")] + public static string GetRunningJobs() + { + return Invoke(connection => + { + var jobs = connection.GetRunningJobs(); + if (jobs.Length == 0) + return "No jobs are running."; + + var sb = new StringBuilder(); + foreach (var job in jobs) + { + sb.Append($"{job.Id} {job.Description}"); + if (job.PercentComplete >= 0) + sb.Append($" {job.PercentComplete}%"); + if (!string.IsNullOrEmpty(job.Message)) + sb.Append($" {job.Message}"); + if (job.CancelRequested) + sb.Append(" (cancel requested)"); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + }); + } + + [McpServerTool(Name = "skyline_cancel_job"), + Description("Ask a running job to stop, by the id skyline_get_running_jobs reports. " + + "The job stops at its next cancellation check, so call skyline_get_running_jobs again to see it go.")] + public static string CancelJob( + [Description("Job id from skyline_get_running_jobs.")] string jobId) + { + return Invoke(connection => + { + var result = connection.CancelJob(jobId); + if (!result.Completed) + return result.Message ?? $"No job {jobId} is running."; + return $"Job {jobId} has been asked to stop. Call skyline_get_running_jobs to see whether it has."; + }); + } + [McpServerTool(Name = "skyline_get_undo_redo"), Description("Get the full undo/redo stack with descriptions and indices. " + "Index -1 = most recent undoable change, -2 = next oldest, etc. " + @@ -1600,8 +1644,9 @@ private static string DescribeAction(ActionResult result, string doneMessage) private const string CALL_TIMED_OUT_MESSAGE = "This call did not finish in time and was abandoned, so the connection to Skyline was dropped and Skyline " + "can accept new commands again. Skyline is STILL DOING the work it started (a long document load, an " + - "import) -- nothing was undone or cancelled. Call skyline_get_open_forms to find the progress dialog, then " + - "skyline_dismiss_with_cancel_button on it to actually cancel the operation, or simply wait and retry."; + "import) -- nothing was undone or cancelled. Call skyline_get_running_jobs to see what is still running and " + + "skyline_cancel_job to stop it; for work running behind a progress dialog, call skyline_get_open_forms to " + + "find the dialog and skyline_dismiss_with_cancel_button on it. Or simply wait and retry."; /// /// Runs one call to Skyline, giving up after . The deadline is applied to the response diff --git a/pwiz_tools/Skyline/Skyline.Designer.cs b/pwiz_tools/Skyline/Skyline.Designer.cs index caf61ac4e02..065c1a18ea9 100644 --- a/pwiz_tools/Skyline/Skyline.Designer.cs +++ b/pwiz_tools/Skyline/Skyline.Designer.cs @@ -134,6 +134,7 @@ private void InitializeComponent() this.searchToolsMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator46 = new System.Windows.Forms.ToolStripSeparator(); this.immediateWindowToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.runningJobsMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator47 = new System.Windows.Forms.ToolStripSeparator(); this.optionsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.helpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -205,11 +206,13 @@ private void InitializeComponent() this.statusGeneral.Name = "statusGeneral"; resources.ApplyResources(this.statusGeneral, "statusGeneral"); this.statusGeneral.Spring = true; - // + this.statusGeneral.DoubleClick += new System.EventHandler(this.statusProgress_DoubleClick); + // // statusProgress - // + // this.statusProgress.Name = "statusProgress"; resources.ApplyResources(this.statusProgress, "statusProgress"); + this.statusProgress.DoubleClick += new System.EventHandler(this.statusProgress_DoubleClick); // // buttonShowAllChromatograms // @@ -804,6 +807,7 @@ private void InitializeComponent() this.searchToolsMenuItem, this.toolStripSeparator46, this.immediateWindowToolStripMenuItem, + this.runningJobsMenuItem, this.toolStripSeparator47, this.optionsToolStripMenuItem}); this.toolsMenu.Name = "toolsMenu"; @@ -854,7 +858,13 @@ private void InitializeComponent() this.immediateWindowToolStripMenuItem.Name = "immediateWindowToolStripMenuItem"; resources.ApplyResources(this.immediateWindowToolStripMenuItem, "immediateWindowToolStripMenuItem"); this.immediateWindowToolStripMenuItem.Click += new System.EventHandler(this.immediateWindowToolStripMenuItem_Click); - // + // + // runningJobsMenuItem + // + this.runningJobsMenuItem.Name = "runningJobsMenuItem"; + resources.ApplyResources(this.runningJobsMenuItem, "runningJobsMenuItem"); + this.runningJobsMenuItem.Click += new System.EventHandler(this.runningJobsMenuItem_Click); + // // toolStripSeparator47 // this.toolStripSeparator47.Name = "toolStripSeparator47"; @@ -1112,6 +1122,7 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem optionsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem publishMenuItem; private System.Windows.Forms.ToolStripMenuItem immediateWindowToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem runningJobsMenuItem; private System.Windows.Forms.ToolStripMenuItem importPeptideSearchMenuItem; // groupReplicatesByContextMenuItem, groupByReplicateContextMenuItem moved to ContextMenuControl private System.Windows.Forms.ToolStripMenuItem mProphetFeaturesMenuItem; diff --git a/pwiz_tools/Skyline/Skyline.cs b/pwiz_tools/Skyline/Skyline.cs index f7b206f45b2..349728972a4 100644 --- a/pwiz_tools/Skyline/Skyline.cs +++ b/pwiz_tools/Skyline/Skyline.cs @@ -1149,6 +1149,14 @@ protected override void OnClosing(CancelEventArgs e) { e.Cancel = false; + // Before anything else, including the offer to save: it would be worse to ask about saving and only + // then refuse to close. + if (!CheckBackgroundJobs()) + { + e.Cancel = true; + return; + } + if (!CheckSaveDocument()) { e.Cancel = true; @@ -2894,6 +2902,86 @@ private void immediateWindowToolStripMenuItem_Click(object sender, EventArgs e) ShowImmediateWindow(); } + private void runningJobsMenuItem_Click(object sender, EventArgs e) + { + ShowRunningJobsDlg(); + } + + // The status bar is where a background job's progress shows, so double-clicking it is the direct way to + // the list the progress came from - the same dialog as Tools > Running Jobs. + private void statusProgress_DoubleClick(object sender, EventArgs e) + { + ShowRunningJobsDlg(); + } + + public void ShowRunningJobsDlg() + { + using (var dlg = new RunningJobsDlg()) + { + dlg.ShowDialog(this); + } + } + + /// + /// Sees the background jobs off before the window closes: asks whether to stop what is still running, + /// and then waits for it to stop. A job goes on writing its file and reporting progress on its own thread, + /// and closing the window out from under one would leave it writing into a process being torn down. + /// + /// Returns false to stay in Skyline - the user said no, or gave up on the wait. + /// + private bool CheckBackgroundJobs() + { + var running = BackgroundJobs.Running; + if (running.Length == 0) + { + return true; + } + + // Jobs that have already been asked to stop are not worth asking about again - they are only worth + // waiting for, which is what happens below. + var uncanceled = running.Where(job => !BackgroundJobs.IsCancelRequested(job.JobId)).ToArray(); + if (uncanceled.Length > 0) + { + string message = uncanceled.Length == 1 + ? string.Format( + SkylineResources.SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__The_job___0___is_still_running__Do_you_want_to_stop_it_, + uncanceled[0].Description) + : string.Format( + SkylineResources.SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__Do_you_want_to_stop_the__0__jobs_that_are_still_running_, + uncanceled.Length); + if (MultiButtonMsgDlg.Show(this, message, MessageBoxButtons.OKCancel) != DialogResult.OK) + { + return false; + } + BackgroundJobs.CancelAll(); + } + + return WaitForBackgroundJobs(); + } + + /// + /// Waits for the stopping jobs to actually end, which they do at their own next cancellation check + /// rather than at once. The wait is itself cancellable: giving up on it stays in Skyline, with the jobs + /// still stopping. + /// + private bool WaitForBackgroundJobs() + { + using (var longWaitDlg = new LongWaitDlg()) + { + longWaitDlg.Message = SkylineResources.SkylineWindow_WaitForBackgroundJobs_Waiting_for_background_jobs_to_end; + // The delay is what keeps this invisible in the ordinary case, where the jobs stop long before it + // elapses and no dialog is ever shown. + longWaitDlg.PerformWork(this, 500, (ILongWaitBroker broker) => + { + while (!broker.IsCanceled && BackgroundJobs.Running.Length > 0) + { + Thread.Sleep(100); + } + }); + return !longWaitDlg.IsCanceled; + } + } + public void ShowImmediateWindow() { if (_immediateWindow != null) @@ -4170,6 +4258,22 @@ public bool StatusContains(string format) return statusGeneral.Text.Contains(start) && statusGeneral.Text.Contains(end); } + /// + /// A snapshot of the progress being reported right now - what the status bar is showing, and everything + /// queued behind it. A copy, because the list is written by the threads doing the work, so it must not be + /// enumerated outside its lock. + /// + public IProgressStatus[] ProgressStatuses + { + get + { + lock (_listProgress) + { + return _listProgress.ToArray(); + } + } + } + public int StatusBarHeight { get { return statusStrip.Height; } } public int StatusSelectionWidth diff --git a/pwiz_tools/Skyline/Skyline.csproj b/pwiz_tools/Skyline/Skyline.csproj index 1170b3f22d5..0e8211b48ec 100644 --- a/pwiz_tools/Skyline/Skyline.csproj +++ b/pwiz_tools/Skyline/Skyline.csproj @@ -1429,6 +1429,8 @@ + + @@ -1675,6 +1677,12 @@ LongWaitDlg.cs + + Form + + + RunningJobsDlg.cs + Form @@ -6964,6 +6972,10 @@ LongWaitDlg.cs Designer + + RunningJobsDlg.cs + Designer + TreeViewMS.cs Designer diff --git a/pwiz_tools/Skyline/Skyline.resx b/pwiz_tools/Skyline/Skyline.resx index 6e01eb45256..ac56fe06a06 100644 --- a/pwiz_tools/Skyline/Skyline.resx +++ b/pwiz_tools/Skyline/Skyline.resx @@ -832,6 +832,12 @@ &Immediate Window + + 178, 22 + + + &Running Jobs... + 175, 6 @@ -1565,6 +1571,12 @@ System.Windows.Forms.ToolStripMenuItem, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + runningJobsMenuItem + + + System.Windows.Forms.ToolStripMenuItem, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + toolStripSeparator47 diff --git a/pwiz_tools/Skyline/SkylineResources.designer.cs b/pwiz_tools/Skyline/SkylineResources.designer.cs index 6178981081c..999d9e9749a 100644 --- a/pwiz_tools/Skyline/SkylineResources.designer.cs +++ b/pwiz_tools/Skyline/SkylineResources.designer.cs @@ -2941,6 +2941,35 @@ public static string SkylineWindow_ModifyDocument_Failure_attempting_to_modify_t } } + /// + /// Looks up a localized string similar to Background jobs must be stopped before exiting. Do you want to stop the {0} jobs that are still running?. + /// + public static string SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__Do_you_want_to_stop_the__0__jobs_that_are_still_running_ { + get { + return ResourceManager.GetString("SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__" + + "Do_you_want_to_stop_the__0__jobs_that_are_still_running_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Background jobs must be stopped before exiting. The job '{0}' is still running. Do you want to stop it?. + /// + public static string SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__The_job___0___is_still_running__Do_you_want_to_stop_it_ { + get { + return ResourceManager.GetString("SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__" + + "The_job___0___is_still_running__Do_you_want_to_stop_it_", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Waiting for background jobs to end. + /// + public static string SkylineWindow_WaitForBackgroundJobs_Waiting_for_background_jobs_to_end { + get { + return ResourceManager.GetString("SkylineWindow_WaitForBackgroundJobs_Waiting_for_background_jobs_to_end", resourceCulture); + } + } + /// /// Looks up a localized string similar to An unexpected error has prevented global settings changes from this session from being saved.. /// diff --git a/pwiz_tools/Skyline/SkylineResources.resx b/pwiz_tools/Skyline/SkylineResources.resx index 0193b16769c..0a6d08029bf 100644 --- a/pwiz_tools/Skyline/SkylineResources.resx +++ b/pwiz_tools/Skyline/SkylineResources.resx @@ -980,6 +980,15 @@ Try uninstalling and reinstalling Skyline, or contact your IT department if the Failure attempting to modify the document. + + Background jobs must be stopped before exiting. Do you want to stop the {0} jobs that are still running? + + + Background jobs must be stopped before exiting. The job '{0}' is still running. Do you want to stop it? + + + Waiting for background jobs to end + An unexpected error has prevented global settings changes from this session from being saved. diff --git a/pwiz_tools/Skyline/SkylineTool/IJsonToolService.cs b/pwiz_tools/Skyline/SkylineTool/IJsonToolService.cs index 71edfff1d76..0b09f6f1b8d 100644 --- a/pwiz_tools/Skyline/SkylineTool/IJsonToolService.cs +++ b/pwiz_tools/Skyline/SkylineTool/IJsonToolService.cs @@ -48,6 +48,30 @@ public interface IJsonToolService /// string GetProcessId(); + // --- Jobs --- + + /// + /// Returns the operations THIS SERVICE started that Skyline is still working on. A long call whose caller + /// gave up waiting (the connection was dropped) goes on running as a job, so this is how to find out what + /// is still going and to get the id needed to stop it. + /// + /// Only jobs started through this service are listed. Work the user started - importing results, + /// building a library - is not reported and cannot be cancelled here; it has its own progress UI. + /// + JobInfo[] GetRunningJobs(); + + /// + /// Asks a running job to stop, and reports in whether there was such + /// a job to ask. It is a REQUEST: the job stops at its next cancellation check, so call + /// again to see it go. + /// + /// is false, with the reason in + /// , when no job has that id - which usually means it had already + /// finished. + /// + /// The of the job to stop. + ActionResult CancelJob(string jobId); + // --- Document info --- /// diff --git a/pwiz_tools/Skyline/SkylineTool/JsonToolModels.cs b/pwiz_tools/Skyline/SkylineTool/JsonToolModels.cs index 323ce8901a7..d03033805ed 100644 --- a/pwiz_tools/Skyline/SkylineTool/JsonToolModels.cs +++ b/pwiz_tools/Skyline/SkylineTool/JsonToolModels.cs @@ -430,6 +430,28 @@ public class LocationEntry public string Locator { get; set; } } + /// + /// One operation Skyline is still working on that was started through this service, returned by + /// . Only these can be cancelled: work the user started + /// (a results import, a library build) is not reported here and is not a caller's to stop. + /// + public class JobInfo + { + /// The job's identifier - what takes. + public string Id { get; set; } + /// What the job is, in the terms of the call that started it ("Exporting report 'Peak Areas'"). + public string Description { get; set; } + /// What the job is doing at the moment ("Writing row 5,000 / 20,000"), or empty before it says. + public string Message { get; set; } + /// How far along the job is, or -1 when it cannot say (an operation of unknown length). + public int PercentComplete { get; set; } + /// + /// True once has been called for this job. It is still listed: + /// a job stops at its next cancellation check, so it can appear here for a moment after being cancelled. + /// + public bool CancelRequested { get; set; } + } + /// /// A single entry in the undo/redo stack returned by GetUndoRedo. /// Negative index = undo step, positive = redo step. diff --git a/pwiz_tools/Skyline/SkylineTool/SkylineJsonToolClient.cs b/pwiz_tools/Skyline/SkylineTool/SkylineJsonToolClient.cs index 00aca1fe0c5..851ca85f667 100644 --- a/pwiz_tools/Skyline/SkylineTool/SkylineJsonToolClient.cs +++ b/pwiz_tools/Skyline/SkylineTool/SkylineJsonToolClient.cs @@ -151,6 +151,8 @@ public SkylineJsonToolClient(NamedPipeClientStream pipe) public string[] GetSettingsListTypes() { return CallTyped(nameof(GetSettingsListTypes)); } public TutorialListItem[] GetAvailableTutorials() { return CallTyped(nameof(GetAvailableTutorials)); } public string GetProcessId() { return Call(nameof(GetProcessId)); } + public JobInfo[] GetRunningJobs() { return CallTyped(nameof(GetRunningJobs)); } + public ActionResult CancelJob(string jobId) { return CallTyped(nameof(CancelJob), jobId); } public int ModalNestingCount() { return CallTyped(nameof(ModalNestingCount)); } public FormInfo[] GetOpenForms() { return CallTyped(nameof(GetOpenForms)); } public ControlInfo[] GetControls(string formId) { return CallTyped(nameof(GetControls), formId); } diff --git a/pwiz_tools/Skyline/TestFunctional/JsonToolJobsTest.cs b/pwiz_tools/Skyline/TestFunctional/JsonToolJobsTest.cs new file mode 100644 index 00000000000..22342cbef48 --- /dev/null +++ b/pwiz_tools/Skyline/TestFunctional/JsonToolJobsTest.cs @@ -0,0 +1,200 @@ +/* + * Original author: Nicholas Shulman , + * MacCoss Lab, Department of Genome Sciences, UW + * AI assistance: Claude Code (Claude Opus 5) + * + * Copyright 2026 University of Washington - Seattle, WA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using pwiz.Common.SystemUtil; +using pwiz.Skyline; +using pwiz.Skyline.ToolsUI; +using pwiz.Skyline.Util.Extensions; +using pwiz.SkylineTestUtil; +using SkylineTool; + +namespace pwiz.SkylineTestFunctional +{ + /// + /// Verifies the job verbs a client uses to deal with a call it gave up on: GetRunningJobs, which reports what + /// this service started and is still working on, and CancelJob, which stops one by id. + /// + /// Everything runs over a real pipe connection, because these two calls only matter to a caller that is + /// not the one that started the work -- and the wire format they answer in is part of what is being tested. + /// + [TestClass] + public class JsonToolJobsTest : McpConnectorTest + { + private const string JOB_DESCRIPTION = @"Test job"; + private const string NOT_A_JOB_MESSAGE = @"Not a job"; + + // How long a wait for the test job to reach a state may take before the test gives up on it. + private const int WAIT_MILLIS = 60 * 1000; + + [TestMethod] + public void TestJsonToolJobs() + { + RunFunctionalTest(); + } + + protected override void DoTest() + { + StartToolService(); + using (var client = SkylineJsonToolClient.Connect(Program.MainJsonToolServer.PipeName)) + { + TestNothingRunning(client); + TestJobListedAndCancelled(client); + TestJobIdNotRunning(client); + TestJobIdNotAnId(client); + TestFinishedVerbLeavesNoJob(client); + } + } + + private static void TestNothingRunning(IJsonToolService client) + { + AssertEx.AreEqual(0, client.GetRunningJobs().Length); + } + + /// + /// The main scenario: a job is running, a client that did not start it lists it and stops it by id, and the + /// job goes away. Progress that is NOT a job runs alongside it throughout, because the point of the + /// JobProgressStatus subclass is that only what this service started is reported and cancellable. + /// + private static void TestJobListedAndCancelled(IJsonToolService client) + { + var progressMonitor = (IProgressMonitor) Program.MainWindow; + var testJob = new TestJob(JOB_DESCRIPTION); + // Progress the user's own work reports -- a results import, a library build. It is in the same list the + // jobs are read from, and must never be reported to a client or be cancellable by one. + IProgressStatus notAJob = new ProgressStatus(NOT_A_JOB_MESSAGE); + try + { + testJob.Start(); + progressMonitor.UpdateProgress(notAJob); + + var jobs = client.GetRunningJobs(); + AssertEx.AreEqual(1, jobs.Length); + var jobInfo = jobs[0]; + AssertEx.AreEqual(testJob.JobId.ToString(), jobInfo.Id); + AssertEx.AreEqual(JOB_DESCRIPTION, jobInfo.Description); + AssertEx.IsFalse(jobInfo.CancelRequested); + + var cancelResult = client.CancelJob(jobInfo.Id); + AssertEx.IsTrue(cancelResult.Completed); + + // The job stops at its next cancellation check, so wait for the job itself to say it has stopped + // rather than for the list to change. + testJob.WaitForStopped(); + AssertEx.AreEqual(0, client.GetRunningJobs().Length); + } + finally + { + // Every exit path, so a failed assertion above does not leave a thread running and the status bar + // reporting progress that never ends. + testJob.Stop(); + progressMonitor.UpdateProgress(notAJob.Complete()); + } + } + + /// + /// Cancelling a job that is not running is not an error - the usual reason is that it finished between + /// being listed and being cancelled, which is the outcome the caller wanted. + /// + private static void TestJobIdNotRunning(IJsonToolService client) + { + var result = client.CancelJob(Guid.NewGuid().ToString()); + AssertEx.IsFalse(result.Completed); + AssertEx.IsFalse(string.IsNullOrEmpty(result.Message)); + } + + private static void TestJobIdNotAnId(IJsonToolService client) + { + AssertEx.ThrowsException(() => client.CancelJob(@"not-a-job-id"), + exception => AssertEx.AreEqual(JsonToolConstants.ERROR_INVALID_PARAMS, exception.Code)); + } + + /// + /// A verb that runs as a job (every report verb does) must take its job back out of the progress list when + /// it finishes - otherwise it would be listed as running forever, and the status bar would say so too. + /// + private static void TestFinishedVerbLeavesNoJob(IJsonToolService client) + { + var definition = new ReportDefinition { Select = new[] { @"ProteinName", @"PrecursorMz" } }; + var rows = client.GetReportFromDefinitionRows(definition, 0, 10, false, + JsonToolConstants.CULTURE_INVARIANT); + AssertEx.IsNotNull(rows); + AssertEx.AreEqual(0, client.GetRunningJobs().Length); + } + + /// + /// Stands in for a long call made through the connector: work run by - + /// the very path every report verb takes - which sits there until it is cancelled. A real verb finishes far + /// too fast on a test document to still be running when the next call arrives, which is the only state + /// these verbs exist for. + /// + private class TestJob + { + private readonly string _description; + private readonly ManualResetEventSlim _started = new ManualResetEventSlim(false); + private readonly ManualResetEventSlim _stopped = new ManualResetEventSlim(false); + private readonly ManualResetEventSlim _released = new ManualResetEventSlim(false); + + public TestJob(string description) + { + _description = description; + } + + /// The job's id, which is what a client lists and cancels it by. + public Guid JobId { get; private set; } + + /// + /// Starts the job, and returns once it is running - so a call made after this really does find it. + /// + public void Start() + { + ActionUtil.RunAsync(() => + { + JsonToolServer.RunJob(_description, (job, cancellationToken) => + { + JobId = job.JobId; + _started.Set(); + // Held here until cancelled through the service, or released by the test. + while (!cancellationToken.IsCancellationRequested && !_released.Wait(100)) + { + } + return true; + }); + _stopped.Set(); + }); + AssertEx.IsTrue(_started.Wait(WAIT_MILLIS)); + } + + public void WaitForStopped() + { + AssertEx.IsTrue(_stopped.Wait(WAIT_MILLIS)); + } + + /// Ends the job without going through the service, and waits for it to stop. + public void Stop() + { + _released.Set(); + _stopped.Wait(WAIT_MILLIS); + } + } + } +} diff --git a/pwiz_tools/Skyline/TestFunctional/LongWaitDlgBackgroundTest.cs b/pwiz_tools/Skyline/TestFunctional/LongWaitDlgBackgroundTest.cs new file mode 100644 index 00000000000..ed426d42cb1 --- /dev/null +++ b/pwiz_tools/Skyline/TestFunctional/LongWaitDlgBackgroundTest.cs @@ -0,0 +1,263 @@ +/* + * Original author: Nicholas Shulman , + * MacCoss Lab, Department of Genome Sciences, UW + * AI assistance: Claude Code (Claude Opus 5) + * + * Copyright 2026 University of Washington - Seattle, WA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using pwiz.Common.SystemUtil; +using pwiz.Skyline; +using pwiz.Skyline.Alerts; +using pwiz.Skyline.Controls; +using pwiz.Skyline.Util; +using pwiz.SkylineTestUtil; + +namespace pwiz.SkylineTestFunctional +{ + /// + /// Verifies the "Run in Background" button on : pressing it must return PerformWork + /// to its caller with the work still running, hand that work to a job the user can see in the status bar and + /// stop, and end the job when the work does. + /// + [TestClass] + public class LongWaitDlgBackgroundTest : AbstractFunctionalTest + { + private const string JOB_DESCRIPTION = @"Test background job"; + private const string WORK_MESSAGE = @"Working on it"; + private const int WORK_PERCENT = 42; + + // Set when the work has reported its first progress, so the test can press the button knowing what the + // job's message and percentage should be. + private readonly ManualResetEventSlim _workStarted = new ManualResetEventSlim(false); + // Set when PerformWork has returned to its caller - which the button must cause, with the work unfinished. + private readonly ManualResetEventSlim _performWorkReturned = new ManualResetEventSlim(false); + // Set to let the work finish when it was not cancelled. Every exit path must set it. + private readonly ManualResetEventSlim _releaseWork = new ManualResetEventSlim(false); + // What StartJob reported, read once PerformWork has returned. + private LongWaitDlg.JobOutcome _outcome; + // Whether the work stops when its job is cancelled. False makes a job that has to be waited out. + private bool _stopOnCancel = true; + + [TestMethod] + public void TestLongWaitDlgBackground() + { + RunFunctionalTest(); + } + + protected override void DoTest() + { + try + { + TestButtonHiddenWithoutDescription(); + TestWorkRunsOnAsAJob(); + } + finally + { + _releaseWork.Set(); + } + } + + /// + /// An operation may be backgrounded only if its caller says so, by naming the job. Without that the button + /// is not there at all. + /// + private void TestButtonHiddenWithoutDescription() + { + StartLongWait(null); + var longWaitDlg = WaitForOpenForm(); + AssertEx.IsFalse(GetBackgroundButtonVisible(longWaitDlg)); + _releaseWork.Set(); + WaitForClosedForm(longWaitDlg); + WaitForPerformWorkReturned(); + ResetForNextRun(); + } + + private void TestWorkRunsOnAsAJob() + { + StartLongWait(JOB_DESCRIPTION); + var longWaitDlg = WaitForOpenForm(); + AssertEx.IsTrue(_workStarted.Wait(WAIT_TIME)); + AssertEx.IsTrue(GetBackgroundButtonVisible(longWaitDlg)); + + RunUI(longWaitDlg.RunInBackground); + + // The dialog is gone and its caller has moved on, told that the work is still going. + WaitForClosedForm(longWaitDlg); + WaitForPerformWorkReturned(); + AssertEx.AreEqual(LongWaitDlg.JobOutcome.backgrounded, _outcome); + + // What the work reported to the dialog now belongs to the job, which is what the status bar shows. + var jobs = BackgroundJobs.Running; + AssertEx.AreEqual(1, jobs.Length); + var job = jobs[0]; + AssertEx.AreEqual(JOB_DESCRIPTION, job.Description); + AssertEx.AreEqual(WORK_MESSAGE, job.Message); + AssertEx.AreEqual(WORK_PERCENT, job.PercentComplete); + + TestExitAsksToStopJobs(job); + TestCancelFromRunningJobsDlg(job); + TestExitWaitsForStoppedJobs(); + } + + /// + /// Exiting with a job running asks whether to stop it, naming the job when there is only one and + /// counting them when there are more. Answering Cancel leaves everything as it was. + /// + private void TestExitAsksToStopJobs(JobProgressStatus job) + { + var messageDlg = ShowDialog(SkylineWindow.Close); + RunUI(() => AssertEx.AreEqual(string.Format( + SkylineResources.SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__The_job___0___is_still_running__Do_you_want_to_stop_it_, + job.Description), + messageDlg.Message)); + OkDialog(messageDlg, messageDlg.BtnCancelClick); + AssertEx.IsFalse(SkylineWindow.IsDisposed); + + // A second job, started directly rather than through a dialog, to see the counted message. + using (BackgroundJobs.Start(JOB_DESCRIPTION)) + { + var messageDlgTwo = ShowDialog(SkylineWindow.Close); + RunUI(() => AssertEx.AreEqual(string.Format( + SkylineResources.SkylineWindow_CheckBackgroundJobs_Background_jobs_must_be_stopped_before_exiting__Do_you_want_to_stop_the__0__jobs_that_are_still_running_, + 2), + messageDlgTwo.Message)); + OkDialog(messageDlgTwo, messageDlgTwo.BtnCancelClick); + } + AssertEx.IsFalse(SkylineWindow.IsDisposed); + } + + /// + /// Answering OK stops the jobs and then WAITS for them to end, because they stop at their own next + /// cancellation check. Giving up on that wait stays in Skyline - and a second attempt to exit goes + /// straight back to the wait, with nothing left to ask about. + /// + private void TestExitWaitsForStoppedJobs() + { + // A job that does NOT stop when asked, so the wait for it can be watched at all. + _stopOnCancel = false; + ResetForNextRun(); + StartLongWait(JOB_DESCRIPTION); + var startedDlg = WaitForOpenForm(); + AssertEx.IsTrue(_workStarted.Wait(WAIT_TIME)); + RunUI(startedDlg.RunInBackground); + WaitForClosedForm(startedDlg); + WaitForPerformWorkReturned(); + + var messageDlg = ShowDialog(SkylineWindow.Close); + var waitDlg = ShowDialog(messageDlg.ClickOk); + OkDialog(waitDlg, waitDlg.CancelButton.PerformClick); + AssertEx.IsFalse(SkylineWindow.IsDisposed); + AssertEx.AreEqual(1, BackgroundJobs.Running.Length); + + // Everything running has been asked to stop by now, so there is nothing left to ask the user: exiting + // goes straight to the wait. + var waitDlgAgain = ShowDialog(SkylineWindow.Close); + OkDialog(waitDlgAgain, waitDlgAgain.CancelButton.PerformClick); + AssertEx.IsFalse(SkylineWindow.IsDisposed); + + // Let the job end, so the test can close Skyline the ordinary way. + _releaseWork.Set(); + WaitForCondition(() => BackgroundJobs.Running.Length == 0); + } + + /// + /// The user's way to the job: Tools > Running Jobs lists it and its Cancel Job button stops it. The same + /// dialog opens on a double-click of the status bar, where the job's progress is showing. + /// + private void TestCancelFromRunningJobsDlg(JobProgressStatus job) + { + var runningJobsDlg = ShowDialog(SkylineWindow.ShowRunningJobsDlg); + RunUI(() => + { + AssertEx.AreEqual(1, runningJobsDlg.JobCount); + AssertEx.AreEqual(job.JobId, runningJobsDlg.SelectedJobId); + runningJobsDlg.CancelSelectedJob(); + }); + + // The work stops at its next cancellation check, and the job goes when it does. + WaitForCondition(() => BackgroundJobs.Running.Length == 0); + WaitForConditionUI(() => runningJobsDlg.JobCount == 0); + OkDialog(runningJobsDlg, runningJobsDlg.Close); + } + + /// + /// Runs the work under a LongWaitDlg on the UI thread, without waiting for it: the test thread has to be + /// free to find the dialog and press its button. null leaves the + /// operation un-backgroundable. + /// + private void StartLongWait(string jobDescription) + { + SkylineWindow.BeginInvoke(new Action(() => + { + using (var longWaitDlg = new LongWaitDlg()) + { + // Work that may be backgrounded goes through StartJob; work that may not keeps to PerformWork, + // which offers no button at all. + if (jobDescription == null) + { + longWaitDlg.PerformWork(SkylineWindow, 0, DoWork); + } + else + { + _outcome = longWaitDlg.StartJob(SkylineWindow, 0, jobDescription, DoWork); + } + } + _performWorkReturned.Set(); + })); + } + + private void DoWork(IProgressMonitor progressMonitor) + { + IProgressStatus status = new ProgressStatus(WORK_MESSAGE).ChangePercentComplete(WORK_PERCENT); + progressMonitor.UpdateProgress(status); + _workStarted.Set(); + // Held here until the job is cancelled, or the test lets it go. A job that ignores the cancellation + // is how the wait for a stopping job is made watchable - see TestExitWaitsForStoppedJobs. + while (!(_stopOnCancel && progressMonitor.IsCanceled) && !_releaseWork.Wait(50)) + { + } + } + + private static bool GetBackgroundButtonVisible(LongWaitDlg longWaitDlg) + { + bool visible = false; + RunUI(() => visible = FindBackgroundButton(longWaitDlg).Visible); + return visible; + } + + private static System.Windows.Forms.Control FindBackgroundButton(LongWaitDlg longWaitDlg) + { + var button = longWaitDlg.Controls.Find(@"btnBackground", true); + AssertEx.AreEqual(1, button.Length); + return button[0]; + } + + private void WaitForPerformWorkReturned() + { + AssertEx.IsTrue(_performWorkReturned.Wait(WAIT_TIME)); + } + + private void ResetForNextRun() + { + _workStarted.Reset(); + _performWorkReturned.Reset(); + _releaseWork.Reset(); + } + } +} diff --git a/pwiz_tools/Skyline/TestFunctional/SkylineMcpTest.cs b/pwiz_tools/Skyline/TestFunctional/SkylineMcpTest.cs index 79a3ccbd50f..19461bb8eb3 100644 --- a/pwiz_tools/Skyline/TestFunctional/SkylineMcpTest.cs +++ b/pwiz_tools/Skyline/TestFunctional/SkylineMcpTest.cs @@ -65,7 +65,7 @@ public void TestSkylineMcp() // shipped stamped 26.1.1.077 while its own info.properties Requires line // demanded 26.1.1.083 - a ZIP that fails its own stated requirement.) // When you rebuild SkylineAiConnector.zip, update this to match. - private const string EXPECTED_ZIP_VERSION = "26.1.1.232"; + private const string EXPECTED_ZIP_VERSION = "26.1.1.233"; // Short FASTA for a quick import test private const string TEST_FASTA = diff --git a/pwiz_tools/Skyline/TestFunctional/TestFunctional.csproj b/pwiz_tools/Skyline/TestFunctional/TestFunctional.csproj index ec32a4ff956..e0eef5750c1 100644 --- a/pwiz_tools/Skyline/TestFunctional/TestFunctional.csproj +++ b/pwiz_tools/Skyline/TestFunctional/TestFunctional.csproj @@ -538,6 +538,8 @@ + + diff --git a/pwiz_tools/Skyline/TestRunnerLib/TestRunnerFormLookup.csv b/pwiz_tools/Skyline/TestRunnerLib/TestRunnerFormLookup.csv index f8173c9e36a..f7893eb8159 100644 --- a/pwiz_tools/Skyline/TestRunnerLib/TestRunnerFormLookup.csv +++ b/pwiz_tools/Skyline/TestRunnerLib/TestRunnerFormLookup.csv @@ -210,6 +210,7 @@ RTGraphController,TestEditDialogs RInstaller,TestRInstaller RTChartPropertyDlg,TestEditDialogs RTDetails,IrtFunctionalTest +RunningJobsDlg,TestLongWaitDlgBackground SaveSettingsDlg,TestShareSettings SchedulingGraphPropertyDlg,TestIrtTutorial ScreenCapturePermissionDlg,TestJsonToolServer diff --git a/pwiz_tools/Skyline/ToolsUI/JsonToolServer.cs b/pwiz_tools/Skyline/ToolsUI/JsonToolServer.cs index 963d935d8ef..82b3c43fd4e 100644 --- a/pwiz_tools/Skyline/ToolsUI/JsonToolServer.cs +++ b/pwiz_tools/Skyline/ToolsUI/JsonToolServer.cs @@ -126,7 +126,11 @@ private class JsonRpcException : Exception private readonly Thread _serverThread; private readonly Dictionary _methods; private volatile bool _stopping; - private ToolLog _currentLog; + + // The diagnostic log of the request being served ON THIS THREAD. Thread-local for the same reason + // _requestCancellation is: a request whose client disconnected goes on running on its own thread while the + // next one is already being served, and the two must not write into each other's log. + private static readonly ThreadLocal _currentLog = new ThreadLocal(); // ===== Client-disconnect cancellation ===== @@ -150,8 +154,8 @@ private class JsonRpcException : Exception private static CancellationToken RequestCancellation => _requestCancellation.Value?.Token ?? CancellationToken.None; - // How often the watchdog peeks the pipe while a request is in flight: free enough to run continuously, quick - // enough that a client which gave up does not wait noticeably for the server to notice it is gone. + // How often the pipe is peeked while a request is in flight: free enough to run continuously, quick enough + // that a client which gave up does not wait noticeably for the server to notice it is gone. private const int DISCONNECT_POLL_MILLIS = 200; public string PipeName { get { return _pipeName; } } @@ -309,52 +313,71 @@ private void ServerLoop() /// up and disconnects abandons it. Without this the server thread stays parked in a long verb (a document /// load riding its LongWaitDlg) and -- being the single instance's only thread -- nothing else can get in, /// not even the request that would cancel the dialog. + /// + /// The request runs on a thread of its own, and only the WAIT for it is given up when the client + /// disconnects: this returns while the WORK GOES ON. That is what frees the single-instance server to take + /// the next connection -- the one that lists what is still running () and stops + /// it (). A verb that watches itself gives up its + /// own wait when the client goes (that is how a verb driving the UI thread lets go of a LongWaitDlg it will + /// never see finish), but a verb with no such wait -- a report export grinding through rows -- has nothing + /// to notice the disconnect with, and would otherwise hold the server for as long as it ran. /// private string HandleRequestWatchingForDisconnect(NamedPipeServerStream pipe, byte[] requestBytes) { - using var cancellation = new CancellationTokenSource(); - // The watchdog runs on ANOTHER thread, so it is given the source directly rather than reading it below. - var watchdog = new Thread(() => WatchForDisconnect(pipe, cancellation)) - { - Name = @"JsonToolServerDisconnectWatchdog-" + _pipeName, - IsBackground = true - }; - watchdog.Start(); - try - { - // Publish it for this thread: the verbs read it (RequestCancellation) and hand it to every element - // they build. Set and cleared inside the try/finally, so a verb that throws leaves nothing behind. - _requestCancellation.Value = cancellation; - return HandleRequest(requestBytes); - } - finally + var cancellation = new CancellationTokenSource(); + // How the thread serving the request reports back. The event is never disposed: nothing asks it for a + // WaitHandle, so it holds no handle to release, and the request may be inside Set() on it right up to + // the moment this returns. + var finished = new ManualResetEventSlim(false); + string response = null; + + ActionUtil.RunAsync(() => { - _requestCancellation.Value = null; - // Cancelling is ALSO how the watchdog is told the request is over -- on every path, not just a - // disconnect. Nothing reads the token by now (the call has returned), so cancelling it costs nothing - // and saves a second signal. Then WAIT for the watchdog before the source is disposed: it may be in - // the middle of cancelling, and cancelling a disposed source throws, on a thread with no one to catch it. - cancellation.Cancel(); - watchdog.Join(); - } - } + try + { + // Published HERE, on the thread that actually serves the request: the verbs read it + // (RequestCancellation) and hand it to every element they build, and a thread-local can + // only be read on the thread that set it. + _requestCancellation.Value = cancellation; + response = HandleRequest(requestBytes); + } + finally + { + _requestCancellation.Value = null; + finished.Set(); // LAST: this is what says the token is no longer being read + } + }, @"JsonToolServerRequest-" + _pipeName); - // Peeks the pipe until the client goes away (abandoning the request) or the request ends -- which the request - // thread signals by cancelling the source, so this parks on the token itself and wakes the moment either - // happens. Takes the source as an argument: it runs on its own thread, so it cannot read the request thread's - // thread-local. - private static void WatchForDisconnect(NamedPipeServerStream pipe, CancellationTokenSource cancellation) - { - while (!cancellation.Token.WaitHandle.WaitOne(DISCONNECT_POLL_MILLIS)) + // Wait for the request, peeking the pipe as we go -- this thread has nothing else to do until one of + // the two happens. Waking to peek costs nothing: the wait returns the instant the request finishes, + // whatever the poll interval, so the interval only bounds how long a client that has gone goes + // unnoticed. (Peeking is the only way to notice: NamedPipeServerStream.IsConnected does not detect a + // disconnect without I/O, and there is no read in progress while a request is being served.) + while (!finished.Wait(DISCONNECT_POLL_MILLIS)) { if (IsClientConnected(pipe)) continue; + + // The client is gone. Tell the verbs still running on the other thread that no one is listening, + // and stop waiting -- the work goes on as a job, and this thread goes back to accept the next + // connection, which is the one that can list and cancel that job. cancellation.Cancel(); - return; + // This response goes nowhere -- writing it fails on the closed pipe, which is what ends this + // connection -- but the server answers every request it reads, and an abandoned call is an error. + // The id is not known here (the request is parsed on the other thread), so it is reported as 0, + // the same as for a request that could not be parsed at all. + return SerializeError(new OperationCanceledException( + @"The client disconnected before this call finished. The work is still running as a job."), + 0, JsonToolConstants.ERROR_INTERNAL); } + + // The request is over, so nothing can be reading the token any more. (An ABANDONED request still is, + // which is why the source is disposed only here, on the path that waited for the end.) + cancellation.Dispose(); + return response; } - // Reliable "client still connected" check for a server thread busy in a verb (no read in progress). + // Reliable "client still connected" check while a request is being served (no read in progress). // NamedPipeServerStream.IsConnected does not detect a disconnect without I/O, so peek the pipe -- // PeekNamedPipe returns false once the client has closed its end. private static bool IsClientConnected(NamedPipeServerStream pipe) @@ -392,7 +415,7 @@ public string HandleRequest(byte[] requestBytes) id = request.Id; JToken[] args = request.Params ?? Array.Empty(); - _currentLog = request.Log ? new ToolLog() : null; + _currentLog.Value = request.Log ? new ToolLog() : null; try { @@ -401,7 +424,7 @@ public string HandleRequest(byte[] requestBytes) } finally { - _currentLog = null; + _currentLog.Value = null; } } catch (JsonReaderException ex) @@ -424,7 +447,7 @@ public string HandleRequest(byte[] requestBytes) /// protected void Log(string message) { - _currentLog?.Write(message); + _currentLog.Value?.Write(message); } private object Dispatch(string method, JToken[] args) @@ -511,6 +534,87 @@ public string GetProcessId() return Process.GetCurrentProcess().Id.ToString(); } + // --- Jobs --- + + public JobInfo[] GetRunningJobs() + { + return BackgroundJobs.Running.Select(job => new JobInfo + { + Id = job.JobId.ToString(), + Description = job.Description, + Message = job.Message, + PercentComplete = job.PercentComplete, + CancelRequested = BackgroundJobs.IsCancelRequested(job.JobId) + }).ToArray(); + } + + public ActionResult CancelJob(string jobId) + { + if (!Guid.TryParse(jobId, out var id)) + { + throw new ArgumentException(LlmInstruction.Format( + @"{0} is not a job id. Job ids come from get_running_jobs.", (jobId ?? string.Empty).SingleQuote())); + } + + if (BackgroundJobs.Cancel(id)) + return new ActionResult { Completed = true }; + + // Not an error: the job most likely finished between the caller listing it and cancelling it, which is + // the outcome the caller wanted anyway. + return new ActionResult + { + Completed = false, + Message = LlmInstruction.Format( + @"No job {0} is running. It has most likely already finished.", jobId.SingleQuote()) + }; + } + + /// + /// Runs as a job: its progress is reported to the main window for as long as it + /// runs, which is both how the user sees it in the status bar and how and + /// find it. That matters when the caller has given up waiting: the request is + /// abandoned, but the work goes on, and a later call can then ask what is still running and stop it. + /// + /// The work is handed the job to report progress against (pass it as the status) and the token that + /// trips, which it must watch to be stoppable -- directly, and through a + /// for work that asks its progress monitor instead. + /// + internal static T RunJob(string description, Func work) + { + using (var job = BackgroundJobs.Start(description)) + { + return work(job.Status, job.CancellationToken); + } + } + + /// + /// Forwards a job's progress to Skyline's status bar (the main window) but answers for cancellation itself, + /// which the main window knows nothing about: reports canceled only when it is + /// closing, so work that asks its monitor whether to stop -- the report exporter asks once per row -- would + /// never see a . + /// + private class JobProgressMonitor : IProgressMonitor + { + private readonly IProgressMonitor _progressMonitor; + private readonly CancellationToken _cancellationToken; + + public JobProgressMonitor(IProgressMonitor progressMonitor, CancellationToken cancellationToken) + { + _progressMonitor = progressMonitor; + _cancellationToken = cancellationToken; + } + + public bool IsCanceled => _cancellationToken.IsCancellationRequested || _progressMonitor.IsCanceled; + + public UpdateProgressResponse UpdateProgress(IProgressStatus status) + { + var response = _progressMonitor.UpdateProgress(status); + return _cancellationToken.IsCancellationRequested ? UpdateProgressResponse.cancel : response; + } + + public bool HasUI => _progressMonitor.HasUI; + } + public string[] GetSettingsListTypes() { return LlmNameMap.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToArray(); @@ -1163,56 +1267,64 @@ public ReportRowsResult GetReportRows(string reportName, int offset, int count, string[] columns, ReportFilter[] filter, bool includeMaxLength, string culture) { ValidateWindow(offset, count); - var localizer = ParseCulture(culture); - var document = Program.MainWindow.Document; - var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); - var rowFactories = RowFactories.GetRowFactories(CancellationToken.None, dataSchema); + return RunJob(string.Format(ToolsUIResources.JsonToolServer_ExportReport_Exporting_report___0__, reportName), + (job, cancellationToken) => + { + var localizer = ParseCulture(culture); + var document = Program.MainWindow.Document; + var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); + var rowFactories = RowFactories.GetRowFactories(cancellationToken, dataSchema); - var viewName = FindReportViewName(reportName); - var viewSpecList = Settings.Default.PersistedViews.GetViewSpecList(viewName.GroupId); - var viewSpec = viewSpecList.GetView(viewName.Name); - var layout = viewSpecList.GetViewLayouts(viewName.Name).DefaultLayout; + var viewName = FindReportViewName(reportName); + var viewSpecList = Settings.Default.PersistedViews.GetViewSpecList(viewName.GroupId); + var viewSpec = viewSpecList.GetView(viewName.Name); + var layout = viewSpecList.GetViewLayouts(viewName.Name).DefaultLayout; - if (filter != null && filter.Length > 0) - viewSpec = ApplyFilterToNamedReport(viewSpec, filter, dataSchema); + if (filter != null && filter.Length > 0) + viewSpec = ApplyFilterToNamedReport(viewSpec, filter, dataSchema); - return MaterializeReportRows(viewSpec, layout, rowFactories, dataSchema, - offset, count, includeMaxLength, columns, reportName, localizer); + return MaterializeReportRows(viewSpec, layout, rowFactories, dataSchema, + offset, count, includeMaxLength, columns, reportName, localizer, job, cancellationToken); + }); } public ReportRowsResult GetReportFromDefinitionRows(ReportDefinition definition, int offset, int count, bool includeMaxLength, string culture) { ValidateWindow(offset, count); - var localizer = ParseCulture(culture); - var document = Program.MainWindow.Document; - var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); - var rowFactories = RowFactories.GetRowFactories(CancellationToken.None, dataSchema); - - var viewSpec = ResolveReportDefinition(definition, dataSchema); - var sortSpecs = ParseSortSpecs(definition); - var rowTransforms = new List(); - if (sortSpecs != null && sortSpecs.Count > 0) - rowTransforms.Add(RowFilter.Empty.SetColumnSorts(sortSpecs)); - if (viewSpec.HasTotals && rowTransforms.Count == 0) - { - var groupByCol = viewSpec.Columns.FirstOrDefault(c => c.Total == TotalOperation.GroupBy); - if (groupByCol != null) + return RunJob(string.Format(ToolsUIResources.JsonToolServer_ExportReport_Exporting_report___0__, + definition.Name ?? JsonToolConstants.DEFAULT_REPORT_NAME), (job, cancellationToken) => + { + var localizer = ParseCulture(culture); + var document = Program.MainWindow.Document; + var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); + var rowFactories = RowFactories.GetRowFactories(cancellationToken, dataSchema); + + var viewSpec = ResolveReportDefinition(definition, dataSchema); + var sortSpecs = ParseSortSpecs(definition); + var rowTransforms = new List(); + if (sortSpecs != null && sortSpecs.Count > 0) + rowTransforms.Add(RowFilter.Empty.SetColumnSorts(sortSpecs)); + if (viewSpec.HasTotals && rowTransforms.Count == 0) { - rowTransforms.Add(RowFilter.Empty.SetColumnSorts(new[] + var groupByCol = viewSpec.Columns.FirstOrDefault(c => c.Total == TotalOperation.GroupBy); + if (groupByCol != null) { - new RowFilter.ColumnSort(new ColumnId(groupByCol.PropertyPath.ToString()), - ListSortDirection.Ascending) - })); + rowTransforms.Add(RowFilter.Empty.SetColumnSorts(new[] + { + new RowFilter.ColumnSort(new ColumnId(groupByCol.PropertyPath.ToString()), + ListSortDirection.Ascending) + })); + } } - } - ViewLayout layout = rowTransforms.Count > 0 - ? new ViewLayout(string.Empty).ChangeRowTransforms(rowTransforms) - : null; + ViewLayout layout = rowTransforms.Count > 0 + ? new ViewLayout(string.Empty).ChangeRowTransforms(rowTransforms) + : null; - string reportName = viewSpec.Name ?? JsonToolConstants.DEFAULT_REPORT_NAME; - return MaterializeReportRows(viewSpec, layout, rowFactories, dataSchema, - offset, count, includeMaxLength, null, reportName, localizer); + string reportName = viewSpec.Name ?? JsonToolConstants.DEFAULT_REPORT_NAME; + return MaterializeReportRows(viewSpec, layout, rowFactories, dataSchema, + offset, count, includeMaxLength, null, reportName, localizer, job, cancellationToken); + }); } public string GetSettingsListItem(string listType, string itemName) @@ -1371,30 +1483,48 @@ public string GetDefaultSettings(string filePath) private ReportMetadata ExportNamedReport(string reportName, string filePath, DataSchemaLocalizer localizer) { Log(string.Format(@"Exporting named report '{0}'", reportName)); - var document = Program.MainWindow.Document; - var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); - var rowFactories = RowFactories.GetRowFactories(CancellationToken.None, dataSchema); + return RunJob(string.Format(ToolsUIResources.JsonToolServer_ExportReport_Exporting_report___0__, reportName), + (job, cancellationToken) => + { + var document = Program.MainWindow.Document; + var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); + var rowFactories = RowFactories.GetRowFactories(cancellationToken, dataSchema); - var viewName = FindReportViewName(reportName); - string ext = Path.GetExtension(filePath); - var exporter = ReportExporters.ForFilenameExtension(localizer, ext, TextUtil.EXT_CSV); + var viewName = FindReportViewName(reportName); + string ext = Path.GetExtension(filePath); + var exporter = ReportExporters.ForFilenameExtension(localizer, ext, TextUtil.EXT_CSV); - DirectoryEx.CreateForFilePath(filePath); + DirectoryEx.CreateForFilePath(filePath); - using (var saver = new FileSaver(filePath, true)) - { - if (!saver.CanSave()) - throw new IOException(LlmInstruction.SpaceSeparate(@"Cannot write to", filePath)); - IProgressStatus status = new ProgressStatus(string.Empty); - rowFactories.ExportReport(saver.Stream, viewName, exporter, - Program.MainWindow, ref status); - saver.Commit(); - } + using (var saver = new FileSaver(filePath, true)) + { + if (!saver.CanSave()) + throw new IOException(LlmInstruction.SpaceSeparate(@"Cannot write to", filePath)); + // The JOB is the status the export reports against, which is what makes its progress the job's + // progress -- the same entry in the status bar, and the percentage GetRunningJobs reports. + IProgressStatus status = job; + rowFactories.ExportReport(saver.Stream, viewName, exporter, + new JobProgressMonitor(Program.MainWindow, cancellationToken), ref status); + // A cancelled export just stops mid-file (its row enumerator ends early), so nothing may be + // committed: throwing leaves the FileSaver to discard the partial file it wrote. + cancellationToken.ThrowIfCancellationRequested(); + saver.Commit(); + } - return BuildReportMetadata(filePath, reportName); + return BuildReportMetadata(filePath, reportName); + }); } private ReportMetadata ExportJsonDefinitionReport(ReportDefinition definition, string filePath, DataSchemaLocalizer localizer) + { + return RunJob(string.Format(ToolsUIResources.JsonToolServer_ExportReport_Exporting_report___0__, + definition.Name ?? JsonToolConstants.DEFAULT_REPORT_NAME), + (job, cancellationToken) => + ExportJsonDefinitionReport(definition, filePath, localizer, job, cancellationToken)); + } + + private ReportMetadata ExportJsonDefinitionReport(ReportDefinition definition, string filePath, + DataSchemaLocalizer localizer, JobProgressStatus job, CancellationToken cancellationToken) { var document = Program.MainWindow.Document; var dataSchema = SkylineDataSchema.MemoryDataSchema(document, localizer, Program.MainWindow.ModeUI); @@ -1402,7 +1532,7 @@ private ReportMetadata ExportJsonDefinitionReport(ReportDefinition definition, s var viewSpec = ResolveReportDefinition(definition, dataSchema); var sortSpecs = ParseSortSpecs(definition); - var rowFactories = RowFactories.GetRowFactories(CancellationToken.None, dataSchema); + var rowFactories = RowFactories.GetRowFactories(cancellationToken, dataSchema); string ext = Path.GetExtension(filePath); var exporter = ReportExporters.ForFilenameExtension(localizer, ext, TextUtil.EXT_CSV); @@ -1443,9 +1573,11 @@ private ReportMetadata ExportJsonDefinitionReport(ReportDefinition definition, s { if (!saver.CanSave()) throw new IOException(LlmInstruction.SpaceSeparate(@"Cannot write to", filePath)); - IProgressStatus status = new ProgressStatus(string.Empty); + // Reported against the job, and cancelled with it - see ExportNamedReport. + IProgressStatus status = job; rowFactories.ExportReport(saver.Stream, viewSpec, layout, exporter, - Program.MainWindow, ref status); + new JobProgressMonitor(Program.MainWindow, cancellationToken), ref status); + cancellationToken.ThrowIfCancellationRequested(); saver.Commit(); } @@ -1673,7 +1805,8 @@ private ViewSpec ApplyFilterToNamedReport(ViewSpec viewSpec, ReportFilter[] filt private ReportRowsResult MaterializeReportRows(ViewSpec viewSpec, ViewLayout layout, RowFactories rowFactories, SkylineDataSchema dataSchema, int offset, int count, bool includeMaxLength, string[] requestedColumns, - string reportName, DataSchemaLocalizer localizer) + string reportName, DataSchemaLocalizer localizer, JobProgressStatus job, + CancellationToken cancellationToken) { if (!rowFactories.TryGetRowSource(viewSpec.RowSource, out var rowSource, out var rowSourceType)) { @@ -1694,11 +1827,11 @@ private ReportRowsResult MaterializeReportRows(ViewSpec viewSpec, ViewLayout lay { if (layout == null || layout.RowTransforms.Count == 0) { - enumerator = viewInfo.GetStreamingRowItemEnumerator(CancellationToken.None, rowSource); + enumerator = viewInfo.GetStreamingRowItemEnumerator(cancellationToken, rowSource); } if (enumerator == null) { - bindingListSource = new BindingListSource(CancellationToken.None); + bindingListSource = new BindingListSource(cancellationToken); if (layout != null) bindingListSource.ApplyLayout(layout); bindingListSource.SetView(viewInfo, rowSource); @@ -1708,6 +1841,9 @@ private ReportRowsResult MaterializeReportRows(ViewSpec viewSpec, ViewLayout lay }; } layout?.ApplyFormats(enumerator.ColumnFormats); + // Reporting against the job is what shows the read in the status bar and gives GetRunningJobs its + // percentage; the monitor is also what ends the row loop below when the job is cancelled. + enumerator.SetProgressMonitor(new JobProgressMonitor(Program.MainWindow, cancellationToken), job); // Total can come from two cheap sources: // - non-streaming: BindingListSource has materialized the rows already @@ -1722,13 +1858,21 @@ private ReportRowsResult MaterializeReportRows(ViewSpec viewSpec, ViewLayout lay ?? enumerator.Length; if (totalLong.HasValue) knownTotal = totalLong.Value > int.MaxValue ? int.MaxValue : (int)totalLong.Value; - return BuildReportRowsResult(enumerator, localizer, offset, count, + var result = BuildReportRowsResult(enumerator, localizer, offset, count, includeMaxLength, requestedColumns, reportName, knownTotal); + // A cancelled read ends its row loop early, which would otherwise be reported as a short report + // rather than as the cancellation it is. + cancellationToken.ThrowIfCancellationRequested(); + return result; } finally { + // Both stay conditional: this also runs when the enumerator is what threw, and there is nothing to + // dispose then. (ReSharper judges only the path that got here normally, where both exist.) + // ReSharper disable ConstantConditionalAccessQualifier enumerator?.Dispose(); bindingListSource?.Dispose(); + // ReSharper restore ConstantConditionalAccessQualifier } } @@ -2007,7 +2151,7 @@ private string SerializeResult(object result, int id) { Result = result, Id = id, - Log = _currentLog?.HasContent == true ? _currentLog.ToString() : null, + Log = _currentLog.Value?.HasContent == true ? _currentLog.Value.ToString() : null, }; return JsonConvert.SerializeObject(response, _snakeCaseSettings); } @@ -2018,7 +2162,7 @@ private string SerializeError(Exception ex, int id, int code) { Error = new JsonRpcError { Code = code, Message = ex.Message }, Id = id, - Log = _currentLog?.HasContent == true ? _currentLog.ToString() : null, + Log = _currentLog.Value?.HasContent == true ? _currentLog.Value.ToString() : null, }; return JsonConvert.SerializeObject(response); } diff --git a/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.designer.cs b/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.designer.cs index 71128ba86c4..87616418a3c 100644 --- a/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.designer.cs +++ b/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.designer.cs @@ -587,6 +587,15 @@ public static string EditServerDlg_OkDialog_Verifying_server_information { } } + /// + /// Looks up a localized string similar to Exporting report '{0}'. + /// + public static string JsonToolServer_ExportReport_Exporting_report___0__ { + get { + return ResourceManager.GetString("JsonToolServer_ExportReport_Exporting_report___0__", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add spectral library. /// diff --git a/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.resx b/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.resx index a212906bdb6..b301b35f7b2 100644 --- a/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.resx +++ b/pwiz_tools/Skyline/ToolsUI/ToolsUIResources.resx @@ -252,6 +252,9 @@ Verifying server information. + + Exporting report '{0}' + Add spectral library diff --git a/pwiz_tools/Skyline/Util/BackgroundJobs.cs b/pwiz_tools/Skyline/Util/BackgroundJobs.cs new file mode 100644 index 00000000000..214fa0f2ea9 --- /dev/null +++ b/pwiz_tools/Skyline/Util/BackgroundJobs.cs @@ -0,0 +1,199 @@ +/* + * Original author: Nicholas Shulman , + * MacCoss Lab, Department of Genome Sciences, UW + * AI assistance: Claude Code (Claude Opus 5) + * + * Copyright 2026 University of Washington - Seattle, WA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using pwiz.Common.SystemUtil; + +namespace pwiz.Skyline.Util +{ + /// + /// The long operations running with nobody waiting for them, which can therefore be listed and stopped by + /// whoever comes along afterwards: a report export whose tool client disconnected, or one the user sent to the + /// background from its . + /// + /// What is running is read from the main window's progress list (only the entries that are a + /// ), so a job's message and percentage are always the ones the work itself last + /// reported - there is no second copy of that to keep up to date. What is kept here is only what the progress + /// list cannot hold: each job's , which has exactly one owner (the + /// handed out by ) and is disposed when that handle is. + /// + public static class BackgroundJobs + { + // Guards every use of a source in it, so one cannot be disposed out from under a cancel. + private static readonly Dictionary _cancellations = + new Dictionary(); + + /// + /// Starts a job and reports it, which is what puts it in the status bar and in . + /// DISPOSE the handle when the work is over: that reports the job's final status, which is what takes it + /// back out again. + /// + /// What the job is, in terms the user will read in the status bar + /// ("Exporting report 'Peak Areas'"). + public static BackgroundJob Start(string description) + { + var job = new BackgroundJob(new JobProgressStatus(description)); + lock (_cancellations) + { + _cancellations.Add(job.JobId, job.CancellationTokenSource); + } + ReportProgress(job.Status); + return job; + } + + /// + /// The jobs running now, in the order they were started. Empty before the main window exists, where there + /// is no progress list to hold them. + /// + public static JobProgressStatus[] Running + { + get + { + var progressStatuses = Program.MainWindow?.ProgressStatuses; + if (progressStatuses == null) + return Array.Empty(); + return progressStatuses.OfType().ToArray(); + } + } + + /// + /// Asks the job with this id to stop, and reports whether there was such a job to ask. It is a REQUEST: + /// the work stops at its next cancellation check, so the job can still be when this + /// returns. False usually means the job had already finished. + /// + public static bool Cancel(Guid jobId) + { + lock (_cancellations) + { + if (!_cancellations.TryGetValue(jobId, out var cancellation)) + return false; + cancellation.Cancel(); + return true; + } + } + + /// + /// Asks every running job to stop. Like it is a REQUEST: the jobs stop at their next + /// cancellation check, so they are still for a moment afterwards. + /// + public static void CancelAll() + { + lock (_cancellations) + { + foreach (var cancellation in _cancellations.Values) + { + cancellation.Cancel(); + } + } + } + + /// + /// True when this job has been asked to stop but has not yet stopped. False for a job that is not running. + /// + public static bool IsCancelRequested(Guid jobId) + { + lock (_cancellations) + { + return _cancellations.TryGetValue(jobId, out var cancellation) && cancellation.IsCancellationRequested; + } + } + + // Reports a job's status to the main window, which is what puts it into -- and takes it back out of -- the + // progress the status bar shows and Running reads. Does nothing before the main window exists (only the + // start page is up), where there is no progress list and no long operation to report to it. + internal static void ReportProgress(IProgressStatus status) + { + ((IProgressMonitor) Program.MainWindow)?.UpdateProgress(status); + } + + internal static void Release(BackgroundJob job) + { + lock (_cancellations) + { + _cancellations.Remove(job.JobId); + job.CancellationTokenSource.Dispose(); + } + } + } + + /// + /// A job while it runs: the identity the user and a tool see it by, the cancellation the work must watch, and + /// the means to report how it is going. Handed out by and disposed by whoever + /// runs the work, once it is over. + /// + public sealed class BackgroundJob : IDisposable + { + internal BackgroundJob(JobProgressStatus status) + { + Status = status; + } + + /// The job's status as it was started - its identity. What the work has reported SINCE is in the + /// main window's progress list, under the same . + public JobProgressStatus Status { get; } + + public Guid JobId => Status.JobId; + + internal CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource(); + + /// Cancelled when the job is cancelled. Watching it is the only way the work can be stopped. + public CancellationToken CancellationToken => CancellationTokenSource.Token; + + public bool IsCancellationRequested => CancellationTokenSource.IsCancellationRequested; + + /// + /// Reports how the job is going, for work that does not report progress itself (work that does simply + /// reports , or a copy of it, and lands in the same place). + /// + /// What the job is doing now, or null to leave the description showing. + /// How far along, or -1 when that is not known. + public void UpdateProgress(string message, int percentComplete) + { + // Never 100: that is a FINAL status, and reporting one takes the job out of the progress list. It is + // Dispose's to report, when the work really is over. -1 passes through - it means "unknown". + if (percentComplete > 99) + percentComplete = 99; + var status = Status.ChangeMessage(message ?? Status.Description); + BackgroundJobs.ReportProgress(status.ChangePercentComplete(percentComplete)); + } + + /// + /// Reports that the job failed, which is what shows the user the error: nothing else will, because a job + /// runs with no caller left to throw to. + /// + public void Failed(Exception exception) + { + BackgroundJobs.ReportProgress(Status.ChangeErrorException(exception)); + } + + /// + /// Ends the job: reports its final status, which takes it out of the progress list and off the status bar, + /// and releases its cancellation. Reporting a status that is already final (the work reported 100%, or + /// did) finds nothing left to replace and does nothing. + /// + public void Dispose() + { + BackgroundJobs.ReportProgress(IsCancellationRequested ? Status.Cancel() : Status.Complete()); + BackgroundJobs.Release(this); + } + } +} diff --git a/pwiz_tools/Skyline/Util/JobProgressStatus.cs b/pwiz_tools/Skyline/Util/JobProgressStatus.cs new file mode 100644 index 00000000000..f62db6c72a1 --- /dev/null +++ b/pwiz_tools/Skyline/Util/JobProgressStatus.cs @@ -0,0 +1,63 @@ +/* + * Original author: Nicholas Shulman , + * MacCoss Lab, Department of Genome Sciences, UW + * AI assistance: Claude Code (Claude Opus 5) + * + * Copyright 2026 University of Washington - Seattle, WA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using pwiz.Common.SystemUtil; + +namespace pwiz.Skyline.Util +{ + /// + /// The progress status of a JOB: a long operation that goes on running with nobody waiting for it, which can + /// be listed and stopped afterwards by whoever comes along - a tool through + /// , once the call that started it has been abandoned, or the user, + /// once they have sent a to the background. + /// + /// Being a status of this type is what makes an operation controllable that way. The main window's + /// progress list holds the status of everything that reports progress - a results import, a library build, a + /// background loader - and most of that has an owner already. Only a is + /// reported as a job, and only by its can one be cancelled. + /// + /// says what the job IS ("Exporting report 'Peak Areas'") and does not change, + /// unlike the inherited Message, which the work rewrites as it advances ("Writing row 5,000 / 20,000"). + /// + /// This is an identity only - what can be told about a job, and what names it to cancel one. The + /// cancellation itself is NOT here: a status is immutable and freely copied (the work reports a new copy for + /// every progress update), which leaves no one place to own a CancellationTokenSource or to dispose it. + /// keeps those, keyed by , for as long as the job runs. + /// + public class JobProgressStatus : ProgressStatus + { + public JobProgressStatus(string description) : base(description) + { + JobId = Guid.NewGuid(); + Description = description; + } + + /// + /// Identifies this job. It is what a cancel names, and it survives every immutable copy the work makes as + /// it reports progress, so the copy in the progress list names the same job. + /// + public Guid JobId { get; } + + /// + /// What the job is, in the terms of the call that started it. Fixed for the job's lifetime. + /// + public string Description { get; } + } +}