fix(claude-code): bound the login flow and cover the held-Start path
This commit is contained in:
@@ -121,9 +121,15 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
public async Task<LoginOutcome> RunLoginFlowAsync(Func<CancellationToken, Task<IUserInterface?>> loginUi, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loginUi);
|
||||
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
|
||||
var ui = await loginUi(ct).ConfigureAwait(false);
|
||||
if (ui is null) return LoginOutcome.Failed("the owner could not be reached; the first allowed user must start a chat with the bot");
|
||||
const string unreachable = "the owner could not be reached; the first allowed user must start a chat with the bot";
|
||||
IUserInterface? ui;
|
||||
using (var resolve = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
resolve.CancelAfter((Options.LoginOptions ?? new ClaudeLoginOptions()).OwnerTimeout);
|
||||
try { ui = await loginUi(resolve.Token).WaitAsync(resolve.Token).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { ui = null; }
|
||||
}
|
||||
if (ui is null) return LoginOutcome.Failed(unreachable);
|
||||
return await LoginThroughAsync(ui, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -131,9 +137,10 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
public async Task<LoginOutcome> LoginThroughAsync(IUserInterface ui, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ui);
|
||||
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
|
||||
var claudePath = Options.ClaudePath;
|
||||
if (string.IsNullOrEmpty(claudePath) || !File.Exists(claudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
|
||||
var env = ClaudeArgs.AllowListed(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null);
|
||||
var outcome = await ClaudeLogin.RunAsync(Options.ClaudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false);
|
||||
var outcome = await ClaudeLogin.RunAsync(claudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false);
|
||||
if (outcome.Success) ui.Notify("Claude Code login complete ✓");
|
||||
else ui.Notify($"Claude Code login failed: {outcome.Error}", NotifyKind.Error);
|
||||
return outcome;
|
||||
@@ -162,9 +169,19 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
var terminatedForHandoff = false;
|
||||
var responseAnnounced = false;
|
||||
var wroteAny = false;
|
||||
var responsePending = false;
|
||||
AssistantMessageEvent? heldStart = null;
|
||||
|
||||
// A deferring attempt holds the init Start back until content follows, so a replay never publishes two Starts.
|
||||
// After-response observers run once per published message, ahead of its Start.
|
||||
async ValueTask AnnounceResponseAsync()
|
||||
{
|
||||
if (!responsePending) return;
|
||||
responsePending = false;
|
||||
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A deferring attempt holds the init Start (and the response announcement) back until something else is published,
|
||||
// so a replay never publishes two Starts and a dropped attempt is never announced.
|
||||
async ValueTask PublishAsync(AssistantMessageEvent e)
|
||||
{
|
||||
if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start)
|
||||
@@ -172,6 +189,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
heldStart = e;
|
||||
return;
|
||||
}
|
||||
await AnnounceResponseAsync().ConfigureAwait(false);
|
||||
if (heldStart is { } held)
|
||||
{
|
||||
heldStart = null;
|
||||
@@ -270,7 +288,8 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
// A validated initialization is this transport's analogue of a received response; announce it before
|
||||
// any content so after-response observers run ahead of the Start event.
|
||||
responseAnnounced = true;
|
||||
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
|
||||
responsePending = true;
|
||||
if (!deferLoginFailure) await AnnounceResponseAsync().ConfigureAwait(false);
|
||||
}
|
||||
foreach (var e in events) await PublishAsync(e).ConfigureAwait(false);
|
||||
if (mapper.HandoffLatched && !terminatedForHandoff)
|
||||
@@ -295,12 +314,15 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
if (mapper.HandoffLatched)
|
||||
{
|
||||
// The provider terminated Claude on purpose: 143 (SIGTERM), 137 (SIGKILL), or whatever a late exit reports.
|
||||
await AnnounceResponseAsync().ConfigureAwait(false);
|
||||
done = mapper.Complete();
|
||||
}
|
||||
else if (mapper.HasSuccessfulResult)
|
||||
{
|
||||
if (exitCode != 0)
|
||||
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
|
||||
// Announced before Complete finishes the builder, so a throwing observer still gets a failure published.
|
||||
await AnnounceResponseAsync().ConfigureAwait(false);
|
||||
done = mapper.Complete();
|
||||
}
|
||||
else if (mapper.AuthenticationFailed)
|
||||
@@ -325,6 +347,11 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
|
||||
if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
|
||||
if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login;
|
||||
if (responsePending)
|
||||
{
|
||||
// The request is already failing; an observer error must not keep the failure from being published.
|
||||
try { await AnnounceResponseAsync().ConfigureAwait(false); } catch (Exception) { /* already failing */ }
|
||||
}
|
||||
var failure = builder.IsFinished
|
||||
? null
|
||||
: aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex));
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
GetEnv = getEnv,
|
||||
ServerName = _options.ServerName,
|
||||
LoginUi = _options.LoginUi,
|
||||
Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared,
|
||||
Coordinator = _options.Coordinator,
|
||||
LoginOptions = _options.LoginOptions,
|
||||
};
|
||||
var claude = new ClaudeCodeApi(registry, apiOptions);
|
||||
|
||||
@@ -30,6 +30,12 @@ public sealed record ClaudeLoginOptions
|
||||
public TimeSpan UrlTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan ExchangeTimeout { get; init; } = TimeSpan.FromSeconds(60);
|
||||
public TimeSpan StatusTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>How long the person has to paste a code; an unanswered prompt then counts as no code.</summary>
|
||||
public TimeSpan CodeTimeout { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>How long resolving where the owner is reached may take before the owner counts as unreachable.</summary>
|
||||
public TimeSpan OwnerTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -83,7 +89,14 @@ public static partial class ClaudeLogin
|
||||
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
|
||||
}
|
||||
|
||||
var code = (await prompt.AskForCodeAsync(url, attempt, ct).ConfigureAwait(false))?.Trim();
|
||||
string? code;
|
||||
using (var answer = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
answer.CancelAfter(options.CodeTimeout);
|
||||
// WaitAsync bounds a prompt that ignores its token: the coordinator slot and this child must not outlive it.
|
||||
try { code = (await prompt.AskForCodeAsync(url, attempt, answer.Token).WaitAsync(answer.Token).ConfigureAwait(false))?.Trim(); }
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { code = null; }
|
||||
}
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
Kill(process);
|
||||
|
||||
@@ -369,4 +369,171 @@ public class ClaudeCodeApiTests : IDisposable
|
||||
Assert.StartsWith("[auth]", error.Message.ErrorMessage);
|
||||
Assert.Contains("claude auth login", error.Message.ErrorMessage);
|
||||
}
|
||||
|
||||
// ---- deferring mode: every request with a login UI holds its init Start until something else is published ----
|
||||
|
||||
private int _loginRequests;
|
||||
|
||||
/// <summary>A login UI that counts how often a login flow asked for the owner.</summary>
|
||||
private Func<CancellationToken, Task<IUserInterface?>> CountingLoginUi() => _ =>
|
||||
{
|
||||
Interlocked.Increment(ref _loginRequests);
|
||||
return Task.FromResult<IUserInterface?>(new RecordingUi());
|
||||
};
|
||||
|
||||
/// <summary>Collects the stream, noting for each OnResponse call how many Starts the consumer had already seen.</summary>
|
||||
private static async Task<(List<AssistantMessageEvent> Events, List<int> StartsBeforeResponse)> CollectWithResponses(
|
||||
Func<StreamOptions, IAsyncEnumerable<AssistantMessageEvent>> stream, Action<StreamOptions>? configure = null)
|
||||
{
|
||||
var events = new List<AssistantMessageEvent>();
|
||||
var startsBeforeResponse = new List<int>();
|
||||
var options = new StreamOptions
|
||||
{
|
||||
SessionId = "session-1",
|
||||
OnResponse = _ =>
|
||||
{
|
||||
lock (events) startsBeforeResponse.Add(events.Count(e => e is AssistantMessageEvent.Start));
|
||||
return ValueTask.CompletedTask;
|
||||
},
|
||||
};
|
||||
await foreach (var e in stream(options)) lock (events) events.Add(e);
|
||||
return (events, startsBeforeResponse);
|
||||
}
|
||||
|
||||
private string TextFixture()
|
||||
{
|
||||
var fixture = Path.Combine(_root, "text.jsonl");
|
||||
File.WriteAllLines(fixture, [
|
||||
Fixtures.Init().GetRawText(),
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello there\"}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
|
||||
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello there\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}",
|
||||
]);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deferring_text_turn_publishes_one_start_after_one_response()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(FakeClaude($"cat \"{TextFixture()}\"", tail: "exit 0"), loginUi: CountingLoginUi());
|
||||
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
|
||||
|
||||
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
|
||||
Assert.Equal("hello there", Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Message.Text);
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(0, _loginRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deferring_tool_handoff_is_unchanged()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(FakeClaude($"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\""), loginUi: CountingLoginUi());
|
||||
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
|
||||
|
||||
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
|
||||
Assert.Equal(StopReason.ToolUse, Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Reason);
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(0, _loginRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deferring_non_login_failure_after_init_publishes_the_held_start()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'\necho \"boom: fake failure\" >&2", tail: "exit 1"), loginUi: CountingLoginUi());
|
||||
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
|
||||
|
||||
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
|
||||
Assert.StartsWith("[process_exit]", Assert.IsType<AssistantMessageEvent.Error>(events[^1]).Message.ErrorMessage);
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(0, _loginRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deferring_cancellation_after_init_reports_aborted_after_the_held_start()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
using var cts = new CancellationTokenSource();
|
||||
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'"), loginUi: CountingLoginUi());
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// The Start is held, so cancel on the init record itself.
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"),
|
||||
o with { OnProviderStreamEvent = _ => { cts.CancelAfter(100); return ValueTask.CompletedTask; } }, cts.Token));
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"took {stopwatch.Elapsed}");
|
||||
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
|
||||
Assert.Equal(StopReason.Aborted, error.Reason);
|
||||
Assert.Equal("aborted", error.Message.ErrorMessage);
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(0, _loginRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_required_after_content_is_published_without_a_login()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var fixture = Path.Combine(_root, "expired-mid.jsonl");
|
||||
File.WriteAllLines(fixture, [
|
||||
Fixtures.Init().GetRawText(),
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}}",
|
||||
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"api_error_status\":401,\"result\":\"Failed to authenticate. API Error: 401 OAuth token has expired.\",\"usage\":{\"input_tokens\":7,\"output_tokens\":1}}",
|
||||
]);
|
||||
var runs = Path.Combine(_root, "runs");
|
||||
var api = Api(FakeClaude($"echo run >> \"{runs}\"\ncat \"{fixture}\"", tail: "exit 1"), loginUi: CountingLoginUi());
|
||||
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
|
||||
|
||||
Assert.Equal(["Start", "TextStart", "TextDelta", "Error"], events.Select(e => e.GetType().Name).ToArray());
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
|
||||
Assert.StartsWith("[auth] Claude Code session expired", error.Message.ErrorMessage);
|
||||
Assert.Equal("partial", error.Message.Text);
|
||||
Assert.Single(File.ReadAllLines(runs));
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(0, _loginRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replay_after_login_announces_one_response()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("the-code")));
|
||||
|
||||
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
|
||||
|
||||
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
Assert.Single(events, e => e is AssistantMessageEvent.Start);
|
||||
Assert.Equal([0], responses);
|
||||
Assert.Equal(2, Runs());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_lookup_that_never_answers_is_unreachable()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var api = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions
|
||||
{
|
||||
ClaudePath = ExpiringClaude(),
|
||||
GetEnv = _ => null,
|
||||
LoginOptions = new ClaudeLoginOptions { OwnerTimeout = TimeSpan.FromMilliseconds(200) },
|
||||
});
|
||||
|
||||
var outcome = await api.RunLoginFlowAsync(_ => new TaskCompletionSource<IUserInterface?>().Task, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.StartsWith("the owner could not be reached", outcome.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ public class ClaudeLoginTests : IDisposable
|
||||
#!/bin/sh
|
||||
STATE="{{state}}"
|
||||
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
|
||||
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"
|
||||
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"; echo $$ > "$STATE/pid"
|
||||
echo "Opening browser to sign in…"
|
||||
{{(printUrl ? "echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&state=s$n\"" : "exit 3")}}
|
||||
printf "Paste code here if prompted > "
|
||||
@@ -117,4 +117,35 @@ public class ClaudeLoginTests : IDisposable
|
||||
var prompt = new ScriptedPrompt("good") { BeforeAnswer = async ct => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); } };
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unanswered_prompt_times_out_as_no_code_and_ends_the_login_process()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
// The prompt ignores its token entirely, like a host dialog that never returns.
|
||||
var prompt = new ScriptedPrompt("good") { BeforeAnswer = _ => Task.Delay(Timeout.Infinite, CancellationToken.None) };
|
||||
var options = Fast with { CodeTimeout = TimeSpan.FromMilliseconds(300) };
|
||||
|
||||
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, options, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal("no code was entered", outcome.Error);
|
||||
Assert.Single(prompt.Urls);
|
||||
if (!OperatingSystem.IsLinux()) return;
|
||||
var pid = File.ReadAllText(Path.Combine(_root, "state", "pid")).Trim();
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (IsAlive(pid) && DateTime.UtcNow < deadline) await Task.Delay(25);
|
||||
Assert.False(IsAlive(pid), $"claude auth login (pid {pid}) is still running");
|
||||
}
|
||||
|
||||
/// <summary>Alive unless gone from /proc or a zombie awaiting its reaper.</summary>
|
||||
private static bool IsAlive(string pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stat = File.ReadAllText($"/proc/{pid}/stat");
|
||||
return stat[(stat.LastIndexOf(')') + 2)] != 'Z';
|
||||
}
|
||||
catch (IOException) { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user