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 Tools | Alt+T,E |
| Tools → Search Tools | Alt+T,S |
| Tools → Immediate Window | Alt+T,I |
+| Tools → Running Jobs | Alt+T,R |
| Tools → Options | Alt+T,O |
| Help → Home | Alt+H,H |
| Help → Videos | Alt+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 Jobs | Alt+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 Jobs | Alt+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; }
+ }
+}