Fix remote executor authentication timeout recovery (#58355)

## Why

Registration can time out while resolving credentials, before any request is sent. Treating this as a transport timeout prevents a safe retry. Credential resolution timeouts can also incorrectly quarantine credentials when no OAuth refresh exchange has started.

## What changed

- Classify registration deadlines reached before the first send as `RequestNotSent`, allowing the existing registration retry loop to recover. Preserve transport timeout handling once a request has been sent.
- Track unsettled ChatGPT refreshes in `AuthManager` from the start of the OAuth exchange through credential persistence. Cancellation while waiting for the refresh guard or reading credentials does not mark them as unsettled.
- Have the executor auth provider withhold credentials with an unsettled refresh. Preserve uncertainty after a cancelled exchange even if a later refresh fails, while allowing replacement credentials and successfully completed refreshes.

## Testing

Add regression coverage for registration and connection deadlines across auth stages, retrying registration after an unsent credential timeout, recovery after slow replacement PAT metadata, and cancellation before and during OAuth refresh.

GitOrigin-RevId: f4d7ec2db7f79b8d313f1ee52dbd441094856669
This commit is contained in:
jif
2026-10-30 14:56:08 +00:00
committed by copyberry
parent 9630b2e019
commit 9b8c2bb50b
8 changed files with 375 additions and 168 deletions
+20 -5
View File
@@ -213,6 +213,7 @@ enum PatCredentialChange {
Logout,
Unchanged,
UnavailableReplacement,
SlowReplacement,
}
async fn assert_pat_registry_reload(change: PatCredentialChange) -> Result<()> {
@@ -271,9 +272,9 @@ async fn assert_pat_registry_reload(change: PatCredentialChange) -> Result<()> {
PatCredentialChange::Logout | PatCredentialChange::Unchanged
) {
let account = match change {
PatCredentialChange::Rotate | PatCredentialChange::UnavailableReplacement => {
"account-pat-test"
}
PatCredentialChange::Rotate
| PatCredentialChange::UnavailableReplacement
| PatCredentialChange::SlowReplacement => "account-pat-test",
PatCredentialChange::SwitchAccount => "another-account",
PatCredentialChange::Logout | PatCredentialChange::Unchanged => unreachable!(),
};
@@ -297,6 +298,13 @@ async fn assert_pat_registry_reload(change: PatCredentialChange) -> Result<()> {
"chatgpt_plan_type": "enterprise",
"chatgpt_account_is_fedramp": false,
}));
// Exceed registration's 10-second deadline while the detached auth
// resolution can still finish within its own 30-second budget.
let response = if matches!(change, PatCredentialChange::SlowReplacement) {
response.set_delay(Duration::from_secs(11))
} else {
response
};
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.and(header(
@@ -312,7 +320,8 @@ async fn assert_pat_registry_reload(change: PatCredentialChange) -> Result<()> {
let expected_registry_requests = match change {
PatCredentialChange::Rotate
| PatCredentialChange::Unchanged
| PatCredentialChange::UnavailableReplacement => 2,
| PatCredentialChange::UnavailableReplacement
| PatCredentialChange::SlowReplacement => 2,
PatCredentialChange::SwitchAccount | PatCredentialChange::Logout => 1,
};
let second_bearer = if matches!(change, PatCredentialChange::Unchanged) {
@@ -331,7 +340,8 @@ async fn assert_pat_registry_reload(change: PatCredentialChange) -> Result<()> {
let result = match change {
PatCredentialChange::Rotate
| PatCredentialChange::SwitchAccount
| PatCredentialChange::UnavailableReplacement => std::fs::write(
| PatCredentialChange::UnavailableReplacement
| PatCredentialChange::SlowReplacement => std::fs::write(
&auth_path,
r#"{"personal_access_token":"at-synthetic-replacement-pat"}"#,
),
@@ -436,6 +446,11 @@ async fn remote_exec_server_pat_recovers_after_replacement_metadata_outage() ->
assert_pat_registry_reload(PatCredentialChange::UnavailableReplacement).await
}
#[tokio::test]
async fn remote_exec_server_pat_recovers_after_replacement_metadata_timeout() -> Result<()> {
assert_pat_registry_reload(PatCredentialChange::SlowReplacement).await
}
#[tokio::test]
async fn remote_exec_server_gracefully_stops_when_parent_stdin_closes() -> Result<()> {
const ENVIRONMENT_ID: &str = "environment-parent-lifetime";