Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fixup DicomAnonymiser CTP implementation #2053

Merged
merged 56 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
0e6256c
add ProcessWrapper
rkm Dec 17, 2024
c2ad8eb
wip: fixup DicomAnonymiser CTP implementation
rkm Dec 17, 2024
eaef9ea
delete old files
rkm Dec 17, 2024
8139feb
add TODO
rkm Dec 17, 2024
66b49dd
wip: fix errors & tidy test
rkm Dec 17, 2024
abc8cec
fix namespace block
rkm Jan 6, 2025
8b6b3c5
add script to download ctp jar
rkm Jan 6, 2025
1a9ed44
rename file
rkm Jan 6, 2025
0222012
fix namespace block
rkm Jan 6, 2025
c07b304
add SlnDirectoryInfo helper
rkm Jan 6, 2025
f134d03
add DisposableTempDir helper
rkm Jan 6, 2025
bd1274b
enforce correct namespaces and check in build
rkm Jan 6, 2025
28c86cb
add basic test for ctp anon wrapper
rkm Jan 6, 2025
584a3d4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 6, 2025
b5f7376
download ctp-anon-cli jar in CI
rkm Jan 6, 2025
9eb54cb
don't download if already exists
rkm Jan 6, 2025
2c1e6ed
restore IFileSystem
rkm Jan 6, 2025
864d7ee
fix error handling in DicomAnonymiserConsumer
rkm Jan 6, 2025
063a7d6
re-enable & fix DicomAnonymiserConsumer tests
rkm Jan 6, 2025
cb33bff
fix types
rkm Jan 6, 2025
e5aa325
test downloading CTP jar for Windows as well
rkm Jan 7, 2025
4e3fde4
delete old readme
rkm Jan 7, 2025
c19152c
ignore exceptions when deleting tempdir in CI
rkm Jan 7, 2025
94debda
print stderr when CreateProcess fails
rkm Jan 7, 2025
b500a43
fixup test
rkm Jan 7, 2025
85c392c
add MSBUILDDISABLENODEREUSE to try and workaround test suite hanging
rkm Jan 7, 2025
42d75e9
Revert "add MSBUILDDISABLENODEREUSE to try and workaround test suite …
rkm Jan 7, 2025
9b20ac3
try disposing process before throwing
rkm Jan 7, 2025
148bd4d
kill instead of dispose
rkm Jan 7, 2025
c74da02
rework timeout logic
rkm Jan 7, 2025
e9c22f2
increase timeout to 10s
rkm Jan 7, 2025
820f009
try with windows path replacements
rkm Jan 7, 2025
78148f5
Revert "try with windows path replacements"
rkm Jan 7, 2025
463ee1d
add java setup
rkm Jan 7, 2025
0791d8f
try grabbing stderr before killing process
rkm Jan 7, 2025
c78b1f7
add MSBUILDDISABLENODEREUSE to try and workaround test suite hanging
rkm Jan 7, 2025
0a43784
Revert "add MSBUILDDISABLENODEREUSE to try and workaround test suite …
rkm Jan 7, 2025
8cb3e1e
remove stderr logging
rkm Jan 7, 2025
5d21502
Update SmiCtpAnonymiser.cs
jas88 Jan 8, 2025
85f855c
Update SmiCtpAnonymiser.cs
jas88 Jan 8, 2025
258fb6c
Update SmiCtpAnonymiser.cs
jas88 Jan 8, 2025
957ebf2
Update SmiCtpAnonymiser.cs
jas88 Jan 8, 2025
962eaf8
make time unit explicit
rkm Jan 8, 2025
9f6a119
add news file
rkm Jan 8, 2025
a46d54b
always log error data
rkm Jan 8, 2025
d32d0ae
remove READY handler once successful
rkm Jan 8, 2025
bc7da32
pad level in test logger
rkm Jan 8, 2025
038cf0c
adjust logging to/from ctp
rkm Jan 8, 2025
8efd3e6
update AnonymiserFactoryTests
rkm Jan 8, 2025
94b4cb9
keep OnCtpOutputDataReceived
rkm Jan 8, 2025
4f06717
restore unsubscription of error data
rkm Jan 8, 2025
e2c1e7c
check jar runs after download
rkm Jan 8, 2025
96ea5da
test same timeout as passing commit
rkm Jan 8, 2025
560ac09
extract timeout to var and add note
rkm Jan 8, 2025
3ca3e5f
always print ErrorDataReceived
rkm Jan 8, 2025
637f1cb
tidy
rkm Jan 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ dotnet_diagnostic.SYSLIB1045.severity = none
dotnet_diagnostic.IDE0005.severity = error

csharp_style_namespace_declarations = file_scoped:error
dotnet_style_namespace_match_folder = true:error
11 changes: 11 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ jobs:
- name: "[linux] install RDMP databases"
if: ${{ matrix.os == 'linux' }}
run: ${{ env.rdmp-cli-dir }}/rdmp install --createdatabasetimeout 180 ${{ env.rdmp_conn_str }} TEST_
- name: Setup Java JDK
uses: actions/[email protected]
with:
java-version: ${{ env.java-version }}
distribution: ${{ env.java-distribution }}
- name: "Download ctp-anon-cli jar"
run: |
set -euxo pipefail
ctp_jar_ver=$(grep -E 'TEST_CTP_JAR_VERSION = "(.*)";' tests/SmiServices.IntegrationTests/Microservices/DicomAnonymiser/FixtureSetup.cs | cut -d'"' -f2)
./bin/smi/downloadCtpAnonJar.py "${ctp_jar_ver}"
java -jar "./data/ctp/ctp-anon-cli-${ctp_jar_ver}.jar" --version
- name: show dotnet info
run: dotnet --info
- name: build, test, and package dotnet
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
Expand Down
37 changes: 37 additions & 0 deletions bin/smi/downloadCtpAnonJar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import argparse
import os
import sys
import urllib.request
from collections.abc import Sequence

sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
import common # noqa: E402

_CTP_JAR_DIR = f"{common.PROJ_ROOT}/data/ctp"


def main(argv: Sequence[str] | None = None) -> int:

parser = argparse.ArgumentParser()
parser.add_argument(
"version",
)
args = parser.parse_args(argv)

url = (
"https://github.com/SMI/ctp-anon-cli/releases/download/"
f"v{args.version}/ctp-anon-cli-{args.version}.jar"
)
file = os.path.join(_CTP_JAR_DIR, f"ctp-anon-cli-{args.version}.jar")
if os.path.isfile(file):
return 0

print(f"Downloading {url} to {file}")
urllib.request.urlretrieve(url, file)

return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions data/ctp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.jar
File renamed without changes.
2 changes: 1 addition & 1 deletion docs/services/ctp-anonymiser.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ removed all PII from the file, it will not proceed if it cannot insert redacted

## CLI Options

The anonymisation script can be specified using the `-a` option. An example script can be viewed [here](/data/ctp/ctp-whitelist.script).
The anonymisation script can be specified using the `-a` option. An example script can be viewed [here](/data/ctp/ctp-allowlist.script).
1 change: 1 addition & 0 deletions news/2053-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Finish dicom-anonymiser CTP implementation and expand tests
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public static IDicomAnonymiser CreateAnonymiser(GlobalOptions options)
return anonymiserType switch
{
AnonymiserType.DefaultAnonymiser => new DefaultAnonymiser(options),
// TODO(rkm 2021-12-07) Can remove the LGTM ignore once an AnonymiserType is implemented
_ => throw new NotImplementedException($"No case for AnonymiserType '{anonymiserType}'"), // lgtm[cs/constant-condition]
AnonymiserType.SmiCtpAnonymiser => new SmiCtpAnonymiser(options),
_ => throw new NotImplementedException($"No case for AnonymiserType '{anonymiserType}'"),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ public enum AnonymiserType
/// Unused placeholder value
/// </summary>
None = 0,

DefaultAnonymiser = 1,

SmiCtpAnonymiser = 2,
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,137 +2,43 @@
using SmiServices.Common.Messages.Extraction;
using SmiServices.Common.Options;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Abstractions;

namespace SmiServices.Microservices.DicomAnonymiser.Anonymisers;

public class DefaultAnonymiser : IDicomAnonymiser
public class DefaultAnonymiser : IDicomAnonymiser, IDisposable
{
private readonly ILogger _logger = LogManager.GetCurrentClassLogger();
private readonly DicomAnonymiserOptions _options;
private const string _bash = "/bin/bash";
private readonly SmiCtpAnonymiser _ctpAnonymiser;

public DefaultAnonymiser(GlobalOptions globalOptions)
{
if (globalOptions.DicomAnonymiserOptions == null)
throw new ArgumentNullException(nameof(globalOptions));
var dicomAnonymiserOptions = globalOptions.DicomAnonymiserOptions!;

Check warning on line 16 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L16

Added line #L16 was not covered by tests

if (globalOptions.LoggingOptions == null)
throw new ArgumentNullException(nameof(globalOptions));

_options = globalOptions.DicomAnonymiserOptions;
}

/// <summary>
/// Creates a process with the given parameters
/// </summary>
private static Process CreateProcess(string fileName, string arguments, string? workingDirectory = null, Dictionary<string, string>? environmentVariables = null)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = workingDirectory ?? string.Empty
}
};

if (environmentVariables != null)
{
foreach (var variable in environmentVariables)
{
process.StartInfo.EnvironmentVariables[variable.Key] = variable.Value;
}
}

return process;
}

private Process CreateCTPAnonProcess(IFileInfo sourceFile, IFileInfo destFile)
{
string arguments = $"-jar {_options.CtpAnonCliJar} -a {_options.CtpAllowlistScript} -s false {sourceFile} {destFile}";

return CreateProcess("java", arguments);
}

private Process CreatePixelAnonProcess(IFileInfo sourceFile, IFileInfo destFile)
{
string activateCommand = $"source {_options.VirtualEnvPath}/bin/activate";
string arguments = $"-c \"{activateCommand} && {_options.DicomPixelAnonPath}/dicom_pixel_anon.sh -o {destFile} {sourceFile}\"";

return CreateProcess(_bash, arguments, _options.DicomPixelAnonPath);
}

// TODO (da 2024-02-23) Use StructuredReports repository to access SRAnonTool
private Process CreateSRAnonProcess(IFileInfo sourceFile, IFileInfo destFile)
{
string arguments = $"{_options.SRAnonymiserToolPath} -i {sourceFile} -o {destFile} -s /Users/daniyalarshad/EPCC/github/NationalSafeHaven/opt/semehr";
_ = new Dictionary<string, string> { { "SMI_ROOT", $"{_options.SmiServicesPath}" } };

return CreateProcess(_bash, arguments);
_ctpAnonymiser = new SmiCtpAnonymiser(globalOptions);

Check warning on line 18 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L18

Added line #L18 was not covered by tests
}

/// <summary>
/// Anonymises a DICOM file based on image modality
/// </summary>
public ExtractedFileStatus Anonymise(ExtractFileMessage message, IFileInfo sourceFile, IFileInfo destFile, out string anonymiserStatusMessage)
public ExtractedFileStatus Anonymise(IFileInfo sourceFile, IFileInfo destFile, string modality, out string? anonymiserStatusMessage)
{
_logger.Info($"Anonymising {sourceFile} to {destFile}");

if (!RunProcessAndCheckSuccess(CreateCTPAnonProcess(sourceFile, destFile), "CTP Anonymiser"))
var status = _ctpAnonymiser.Anonymise(sourceFile, destFile, modality, out string? ctpStatusMessage);

Check warning on line 26 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L26

Added line #L26 was not covered by tests
if (status != ExtractedFileStatus.Anonymised)
{
anonymiserStatusMessage = "Error running CTP anonymiser";
return ExtractedFileStatus.ErrorWontRetry;
anonymiserStatusMessage = ctpStatusMessage;
return status;

Check warning on line 30 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L29-L30

Added lines #L29 - L30 were not covered by tests
}

if (message.Modality == "SR")
{
if (!RunProcessAndCheckSuccess(CreateSRAnonProcess(sourceFile, destFile), "SR Anonymiser"))
{
anonymiserStatusMessage = "Error running SR anonymiser";
return ExtractedFileStatus.ErrorWontRetry;
}
}
else
{
if (!RunProcessAndCheckSuccess(CreatePixelAnonProcess(sourceFile, destFile), "Pixel Anonymiser"))
{
anonymiserStatusMessage = "Error running PIXEL anonymiser";
return ExtractedFileStatus.ErrorWontRetry;
}
}
// TODO(rkm 2024-12-17) Implement SR anon here (instead of via CTP), and add pixel anonymiser

anonymiserStatusMessage = "Anonymisation successful";
anonymiserStatusMessage = null;

Check warning on line 35 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L35

Added line #L35 was not covered by tests
return ExtractedFileStatus.Anonymised;
}

/// <summary>
/// Runs a process and logs the result
/// </summary>
private bool RunProcessAndCheckSuccess(Process process, string processName)
public void Dispose()
{
process.Start();
process.WaitForExit();

var returnCode = process.ExitCode.ToString();
LogProcessResult(processName, returnCode, process);

return returnCode == "0";
GC.SuppressFinalize(this);
_ctpAnonymiser.Dispose();

Check warning on line 42 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/DefaultAnonymiser.cs#L41-L42

Added lines #L41 - L42 were not covered by tests
}

/// <summary>
/// Logs the result of a process
/// </summary>
private void LogProcessResult(string processName, string returnCode, Process process)
{
var output = returnCode == "0" ? process.StandardOutput.ReadToEnd() : process.StandardError.ReadToEnd();
_logger.Info($"{(returnCode == "0" ? "SUCCESS" : "ERROR")} [{processName}]: Return Code {returnCode}\n{output}");
}

}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ namespace SmiServices.Microservices.DicomAnonymiser.Anonymisers;
public interface IDicomAnonymiser
{
/// <summary>
/// Anonymise the specified <paramref name="sourceFile"/> to <paramref name="destFile"></paramref> based on the provided <paramref name="message"/> modality.
/// Anonymise the specified <paramref name="sourceFile"/> to <paramref name="destFile"></paramref>.
/// Implementations should assume that <paramref name="sourceFile"/> already exists, and <paramref name="destFile"></paramref> does not exist.
/// </summary>
/// <param name="message"></param>
/// <param name="sourceFile"></param>
/// <param name="destFile"></param>
/// <param name="modality"></param>
/// <param name="anonymiserStatusMessage"></param>
/// <returns></returns>
ExtractedFileStatus Anonymise(ExtractFileMessage message, IFileInfo sourceFile, IFileInfo destFile, out string anonymiserStatusMessage);
ExtractedFileStatus Anonymise(IFileInfo sourceFile, IFileInfo destFile, string modality, out string? anonymiserStatusMessage);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using NLog;
using SmiServices.Common.Messages.Extraction;
using SmiServices.Common.Options;
using SmiServices.Microservices.DicomAnonymiser.Helpers;
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Abstractions;
using System.Threading;

namespace SmiServices.Microservices.DicomAnonymiser.Anonymisers;

public class SmiCtpAnonymiser : IDicomAnonymiser, IDisposable
{
private readonly ILogger _logger = LogManager.GetCurrentClassLogger();
private readonly Process _ctpProcess;

// NOTE(rkm 2025-01-08) This sometimes takes more than 10s in CI for some reason
private readonly TimeSpan CTP_TIMEOUT = TimeSpan.FromSeconds(20);

public SmiCtpAnonymiser(GlobalOptions globalOptions)
{
var dicomAnonymiserOptions = globalOptions.DicomAnonymiserOptions ?? throw new ArgumentException($"{nameof(globalOptions.DicomAnonymiserOptions)} was null", nameof(globalOptions));

if (!File.Exists(dicomAnonymiserOptions.CtpAnonCliJar))
throw new ArgumentException($"{nameof(dicomAnonymiserOptions.CtpAnonCliJar)} '{dicomAnonymiserOptions.CtpAnonCliJar}' does not exist", nameof(globalOptions));

if (!File.Exists(dicomAnonymiserOptions.CtpAllowlistScript))
throw new ArgumentException($"{nameof(dicomAnonymiserOptions.CtpAllowlistScript)} '{dicomAnonymiserOptions.CtpAllowlistScript}' does not exist", nameof(globalOptions));

Check warning on line 29 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs#L29

Added line #L29 was not covered by tests

var srAnonTool = "false";
if (!string.IsNullOrWhiteSpace(dicomAnonymiserOptions.SRAnonymiserToolPath))
{
if (!File.Exists(dicomAnonymiserOptions.SRAnonymiserToolPath))
throw new ArgumentException($"{nameof(dicomAnonymiserOptions.SRAnonymiserToolPath)} '{dicomAnonymiserOptions.SRAnonymiserToolPath}' does not exist", nameof(globalOptions));
srAnonTool = dicomAnonymiserOptions.SRAnonymiserToolPath;

Check warning on line 36 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs#L35-L36

Added lines #L35 - L36 were not covered by tests
}

var ctpArgs = $"-jar {dicomAnonymiserOptions.CtpAnonCliJar} --anon-script {dicomAnonymiserOptions.CtpAllowlistScript} --sr-anon-tool {srAnonTool} --daemonize";
_logger.Info($"Starting ctp with: java {ctpArgs}");

var ready = false;
_ctpProcess = ProcessWrapper.CreateProcess("java", ctpArgs);
_ctpProcess.OutputDataReceived += OnCtpOutputDataReceived;
_ctpProcess.ErrorDataReceived += (_, args) => _logger.Debug(args.Data);
_ctpProcess.Start();
_ctpProcess.BeginOutputReadLine();
_ctpProcess.BeginErrorReadLine();

lock (_ctpProcess)
Monitor.Wait(_ctpProcess, CTP_TIMEOUT);

rkm marked this conversation as resolved.
Show resolved Hide resolved
if (!ready)
{
_ctpProcess.Kill();
throw new Exception($"Did not receive READY before timeout");

Check warning on line 56 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs#L55-L56

Added lines #L55 - L56 were not covered by tests
}

void OnCtpOutputDataReceived(object _, DataReceivedEventArgs args)
{
_logger.Debug($"[ctp-anon-cli stdout] {args.Data}");
if ("READY" != args.Data) return;

ready = true;
lock (_ctpProcess)
Monitor.Pulse(_ctpProcess);
}
}

public ExtractedFileStatus Anonymise(IFileInfo sourceFile, IFileInfo destFile, string modality, out string? anonymiserStatusMessage)
{
var args = $"{sourceFile.FullName} {destFile.FullName}";
string? result = null;

_ctpProcess.OutputDataReceived += CtpProcessOnOutputDataReceived;

_logger.Debug($"[ctp-anon-cli stdin ] {args}");
_ctpProcess.StandardInput.WriteLine(args);

lock (args)
Monitor.Wait(args);

_ctpProcess.OutputDataReceived -= CtpProcessOnOutputDataReceived;

ExtractedFileStatus status;
if (result == "OK")
{
anonymiserStatusMessage = null;
status = ExtractedFileStatus.Anonymised;
}
else
{
anonymiserStatusMessage = result;
status = ExtractedFileStatus.ErrorWontRetry;

Check warning on line 94 in src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs

View check run for this annotation

Codecov / codecov/patch

src/SmiServices/Microservices/DicomAnonymiser/Anonymisers/SmiCtpAnonymiser.cs#L93-L94

Added lines #L93 - L94 were not covered by tests
}

return status;

void CtpProcessOnOutputDataReceived(object _, DataReceivedEventArgs e)
{
result = e.Data;
lock (args)
Monitor.Pulse(args);
}
}

public void Dispose()
{
GC.SuppressFinalize(this);
_ctpProcess.Dispose();
}
}
Loading