The footer showed invented percentages. It now shows what two harnesses actually report, or says it does not know. lumbridge-core gains an append-only per-profile UsageLedger and a projection that labels every derived value estimated, withholds a burn rate from a single sample, withholds a window fraction with no reported ceiling, withholds an exhaustion estimate that lands after the reset, and reports an expired window as rolled over rather than freezing its last percentage. A missing fact renders as missing, never as zero. (0012) lumbridge-harness is the impure side: processes, clocks, and untrusted wire text in, observations out. Three adapters: - Codex's account/rateLimits/read over the app-server's JSON-RPC stdio. The client cannot express a request outside a two-variant enum and answers every server-to-client request with -32601, so a harness asking Lumbridge for a credential is refused by construction. (0013) - Claude Code's session transcripts, as a byte-offset tail follower that reports nothing until the backlog is read to EOF — a partially-read backlog is indistinguishable from a burst of spend, and the first run against 20 MB reported forty-six billion tokens an hour. The parser models four counters, so the conversations in those files are not representable. (0014) - Claude Code's five-hour and seven-day subscription windows, via a bridge installed as its statusLine command. 0014 had claimed no such surface existed; it does, and the record is corrected in place rather than quietly edited. Lumbridge does not read the OAuth credential to call the account usage endpoint, which is what comparable tools do — AGENTS.md forbids it, and 0015 says so rather than leaving the gap unexplained. Also in here: a capability-check ordering fix in the workspace reducer, where the applied-request replay table was consulted before the capability check and so answered questions the caller had no right to ask; the GPUI spike wired to the live probes with per-harness gauges and provenance chips; and a launcher that matches its own window by PID, because GPUI sets WM_NAME but not _NET_WM_NAME and a title match never succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
585 lines
21 KiB
Rust
585 lines
21 KiB
Rust
//! The Codex probe's protocol logic, as a pure state machine.
|
|
//!
|
|
//! Every decision the probe makes about what to send, what to record, and what
|
|
//! to refuse happens here, driven by `&str` lines and an explicit timestamp.
|
|
//! No process, no socket, and no clock are involved, so the whole protocol is
|
|
//! testable from string fixtures. The thread in [`super::worker`] does nothing
|
|
//! but move bytes between a child process and this type.
|
|
|
|
use lumbridge_core::{AccountProfile, AccountProfileId, UsageObservation, UsageWindow};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::HarnessError;
|
|
use crate::jsonrpc::{
|
|
AcceptedNotification, INVALID_REQUEST, Inbound, OutboundRequest, ServerRequestClass, classify,
|
|
encode_notification, encode_refusal, encode_request,
|
|
};
|
|
use crate::probe::ProbeHealth;
|
|
|
|
use super::parse::{
|
|
RateLimitSnapshotWire, RateLimitsResultWire, WindowReading, parse_window, to_observation,
|
|
};
|
|
|
|
/// Re-emit an unchanged reading at least this often so the ledger keeps a
|
|
/// baseline for its burn rate. Without a floor, a quiet account would hold one
|
|
/// lone observation and the footer could never derive a rate; without a
|
|
/// ceiling on how often we re-record, the bounded retention would be flushed
|
|
/// of real history by identical rows.
|
|
const MIN_REEMIT_INTERVAL_MS: u64 = 300_000;
|
|
|
|
/// The two windows Codex reports are two separate facts about two separate
|
|
/// quota periods. Merging them would mean silently choosing one.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum WindowSlot {
|
|
Primary,
|
|
Secondary,
|
|
}
|
|
|
|
impl WindowSlot {
|
|
const ALL: [Self; 2] = [Self::Primary, Self::Secondary];
|
|
|
|
/// Static, account-free identifiers. A profile ID is persisted and must
|
|
/// never carry an account identity.
|
|
const fn profile_id(self) -> &'static str {
|
|
match self {
|
|
Self::Primary => "codex-app-server-primary",
|
|
Self::Secondary => "codex-app-server-secondary",
|
|
}
|
|
}
|
|
|
|
/// Codex rate limits are account-scoped, not model-scoped. Naming the
|
|
/// scope is honest; inventing a model name would not be.
|
|
const fn scope(self) -> &'static str {
|
|
match self {
|
|
Self::Primary => "primary window",
|
|
Self::Secondary => "secondary window",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the session wants the caller to do next.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum SessionStep {
|
|
/// Nothing to do.
|
|
Idle,
|
|
/// Write this line to the child's stdin.
|
|
Send(String),
|
|
/// Record these observations.
|
|
Observations(Vec<UsageObservation>),
|
|
/// The probe's liveness changed.
|
|
Health(ProbeHealth),
|
|
/// A server-to-client request was refused. Write the line, and note why.
|
|
Refused {
|
|
line: String,
|
|
class: ServerRequestClass,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum Stage {
|
|
AwaitingInitialize,
|
|
Ready,
|
|
}
|
|
|
|
/// The last thing recorded for one profile, used to suppress identical rows.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct LastRecorded {
|
|
permille: u64,
|
|
window: Option<UsageWindow>,
|
|
at_ms: u64,
|
|
}
|
|
|
|
pub(crate) struct CodexSession {
|
|
profiles: Vec<AccountProfile>,
|
|
stage: Stage,
|
|
next_id: u64,
|
|
initialize_id: u64,
|
|
pending_read_id: Option<u64>,
|
|
last: [Option<LastRecorded>; 2],
|
|
refused_credential_requests: u64,
|
|
}
|
|
|
|
impl CodexSession {
|
|
pub(crate) fn new() -> Result<Self, HarnessError> {
|
|
let profiles = WindowSlot::ALL
|
|
.into_iter()
|
|
.map(|slot| {
|
|
AccountProfile::new(
|
|
slot.profile_id(),
|
|
"Codex",
|
|
"ChatGPT",
|
|
slot.scope(),
|
|
"subscription",
|
|
)
|
|
.map_err(|_| HarnessError::EmptyProgram)
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(Self {
|
|
profiles,
|
|
stage: Stage::AwaitingInitialize,
|
|
next_id: 1,
|
|
initialize_id: 0,
|
|
pending_read_id: None,
|
|
last: [None, None],
|
|
refused_credential_requests: 0,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn profiles(&self) -> &[AccountProfile] {
|
|
&self.profiles
|
|
}
|
|
|
|
/// Whether the handshake has completed and readings may be requested.
|
|
pub(crate) const fn is_ready(&self) -> bool {
|
|
matches!(self.stage, Stage::Ready)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) const fn refused_credential_requests(&self) -> u64 {
|
|
self.refused_credential_requests
|
|
}
|
|
|
|
fn take_id(&mut self) -> u64 {
|
|
let id = self.next_id;
|
|
self.next_id = self.next_id.saturating_add(1);
|
|
id
|
|
}
|
|
|
|
/// The handshake frame. Codex requires `initialize` before any other call.
|
|
pub(crate) fn initialize(&mut self, client_name: &str, client_version: &str) -> String {
|
|
let id = self.take_id();
|
|
self.initialize_id = id;
|
|
encode_request(
|
|
id,
|
|
OutboundRequest::Initialize,
|
|
&json!({ "clientInfo": { "name": client_name, "version": client_version } }),
|
|
)
|
|
}
|
|
|
|
/// Requests a fresh reading, unless one is already outstanding.
|
|
pub(crate) fn poll_frame(&mut self) -> Option<String> {
|
|
if self.stage != Stage::Ready || self.pending_read_id.is_some() {
|
|
return None;
|
|
}
|
|
let id = self.take_id();
|
|
self.pending_read_id = Some(id);
|
|
Some(encode_request(
|
|
id,
|
|
OutboundRequest::ReadAccountRateLimits,
|
|
&json!({}),
|
|
))
|
|
}
|
|
|
|
/// Consumes one line from the child.
|
|
pub(crate) fn on_line(&mut self, line: &str, observed_at_ms: u64) -> SessionStep {
|
|
let Some(inbound) = classify(line) else {
|
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed));
|
|
};
|
|
match inbound {
|
|
Inbound::ServerRequest { id, class } => {
|
|
if class == ServerRequestClass::CredentialRefresh {
|
|
self.refused_credential_requests =
|
|
self.refused_credential_requests.saturating_add(1);
|
|
}
|
|
SessionStep::Refused {
|
|
line: encode_refusal(&id),
|
|
class,
|
|
}
|
|
}
|
|
Inbound::Result { id, result } => self.on_result(id, &result, observed_at_ms),
|
|
Inbound::Failure { id, code } => self.on_failure(id, code, observed_at_ms),
|
|
// Matching the kind exhaustively means adding a second accepted
|
|
// notification cannot silently reuse the rate-limit path.
|
|
Inbound::Notification {
|
|
kind: AcceptedNotification::AccountRateLimitsUpdated,
|
|
params,
|
|
} => self.on_snapshot_body(¶ms, observed_at_ms, true),
|
|
Inbound::Ignored => SessionStep::Idle,
|
|
}
|
|
}
|
|
|
|
fn on_result(&mut self, id: u64, result: &Value, observed_at_ms: u64) -> SessionStep {
|
|
if self.stage == Stage::AwaitingInitialize && id == self.initialize_id {
|
|
self.stage = Stage::Ready;
|
|
return SessionStep::Send(encode_notification("initialized", &json!({})));
|
|
}
|
|
if self.pending_read_id == Some(id) {
|
|
self.pending_read_id = None;
|
|
return self.on_snapshot_body(result, observed_at_ms, false);
|
|
}
|
|
SessionStep::Idle
|
|
}
|
|
|
|
fn on_failure(&mut self, id: u64, code: i64, observed_at_ms: u64) -> SessionStep {
|
|
if self.pending_read_id == Some(id) {
|
|
self.pending_read_id = None;
|
|
// Codex answers -32600 when the account cannot report a quota,
|
|
// which is a true statement about the account rather than a fault.
|
|
if code == INVALID_REQUEST {
|
|
let observations = self.unavailable_for_all(observed_at_ms);
|
|
self.forget_all();
|
|
return SessionStep::Observations(observations);
|
|
}
|
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code)));
|
|
}
|
|
if id == self.initialize_id {
|
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code)));
|
|
}
|
|
SessionStep::Idle
|
|
}
|
|
|
|
/// Turns a `rateLimits` value into observations.
|
|
///
|
|
/// `sparse` marks a push notification. Upstream documents that a rolling
|
|
/// update may omit values and that omission "does not clear a previously
|
|
/// observed value", so an absent window on a push records nothing at all.
|
|
/// It must not record an explicit gap, and it must not be merged into the
|
|
/// last full read either: a merged composite would look provider-reported
|
|
/// while being partly a memory.
|
|
fn on_snapshot_body(&mut self, body: &Value, observed_at_ms: u64, sparse: bool) -> SessionStep {
|
|
let Ok(body) = serde_json::from_value::<RateLimitsResultWire>(body.clone()) else {
|
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed));
|
|
};
|
|
let observations = self.observations_from(&body.rate_limits, observed_at_ms, sparse);
|
|
if observations.is_empty() {
|
|
SessionStep::Idle
|
|
} else {
|
|
SessionStep::Observations(observations)
|
|
}
|
|
}
|
|
|
|
fn observations_from(
|
|
&mut self,
|
|
snapshot: &RateLimitSnapshotWire,
|
|
observed_at_ms: u64,
|
|
sparse: bool,
|
|
) -> Vec<UsageObservation> {
|
|
let mut observations = Vec::new();
|
|
for slot in WindowSlot::ALL {
|
|
let index = slot as usize;
|
|
let wire = match slot {
|
|
WindowSlot::Primary => snapshot.primary.as_ref(),
|
|
WindowSlot::Secondary => snapshot.secondary.as_ref(),
|
|
};
|
|
let Some(wire) = wire else {
|
|
if !sparse {
|
|
// A full read that omits a window means the window is gone.
|
|
if self.last[index].take().is_some() {
|
|
observations.push(UsageObservation::unavailable(
|
|
self.profile_id(index),
|
|
observed_at_ms,
|
|
));
|
|
}
|
|
}
|
|
continue;
|
|
};
|
|
let reading = parse_window(wire, observed_at_ms);
|
|
if !self.should_record(index, reading, observed_at_ms) {
|
|
continue;
|
|
}
|
|
observations.push(to_observation(
|
|
&self.profile_id(index),
|
|
reading,
|
|
observed_at_ms,
|
|
));
|
|
}
|
|
observations
|
|
}
|
|
|
|
fn profile_id(&self, index: usize) -> AccountProfileId {
|
|
self.profiles[index].id().clone()
|
|
}
|
|
|
|
/// Suppresses an identical reading unless the re-emit floor has passed.
|
|
fn should_record(&mut self, index: usize, reading: WindowReading, observed_at_ms: u64) -> bool {
|
|
let WindowReading::Usable { permille, window } = reading else {
|
|
// A gap is only worth recording when it changes the story.
|
|
return self.last[index].take().is_some();
|
|
};
|
|
let unchanged = self.last[index].is_some_and(|last| {
|
|
last.permille == permille
|
|
&& last.window == window
|
|
&& observed_at_ms.saturating_sub(last.at_ms) < MIN_REEMIT_INTERVAL_MS
|
|
});
|
|
if unchanged {
|
|
return false;
|
|
}
|
|
self.last[index] = Some(LastRecorded {
|
|
permille,
|
|
window,
|
|
at_ms: observed_at_ms,
|
|
});
|
|
true
|
|
}
|
|
|
|
/// An explicit gap for every profile, stamped with when the gap was
|
|
/// observed.
|
|
///
|
|
/// Stamping these from the last successful reading — or from the epoch when
|
|
/// there has never been one — would put them behind the ledger's newest
|
|
/// entry, and an append-only stream rejects a backwards timestamp. The gap
|
|
/// would then be silently dropped and a stale percentage would keep
|
|
/// rendering as current, which is the exact failure decision 0012 exists to
|
|
/// prevent.
|
|
fn unavailable_for_all(&self, observed_at_ms: u64) -> Vec<UsageObservation> {
|
|
(0..self.profiles.len())
|
|
.map(|index| UsageObservation::unavailable(self.profile_id(index), observed_at_ms))
|
|
.collect()
|
|
}
|
|
|
|
fn forget_all(&mut self) {
|
|
self.last = [None, None];
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{CodexSession, SessionStep, WindowSlot};
|
|
use crate::jsonrpc::ServerRequestClass;
|
|
use crate::probe::ProbeHealth;
|
|
use lumbridge_core::{UsageProvenance, UsageUnit};
|
|
|
|
const HOUR_MS: u64 = 3_600_000;
|
|
const RESETS_AT_SECONDS: i64 = 1_730_947_200;
|
|
const RESETS_AT_MS: u64 = 1_730_947_200_000;
|
|
|
|
fn session() -> CodexSession {
|
|
CodexSession::new().expect("the session profiles are valid")
|
|
}
|
|
|
|
fn ready() -> CodexSession {
|
|
let mut session = session();
|
|
let _ = session.initialize("lumbridge", "0.0.1");
|
|
let step = session.on_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, 0);
|
|
assert!(matches!(step, SessionStep::Send(_)), "handshake completes");
|
|
session
|
|
}
|
|
|
|
fn read_response(id: u64, percent: i64, minutes: i64) -> String {
|
|
format!(
|
|
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"rateLimits":{{"primary":{{"usedPercent":{percent},"windowDurationMins":{minutes},"resetsAt":{RESETS_AT_SECONDS}}},"secondary":null}}}}}}"#
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn the_two_windows_are_two_separate_account_free_profiles() {
|
|
let session = session();
|
|
let ids = session
|
|
.profiles()
|
|
.iter()
|
|
.map(|profile| profile.id().as_str().to_owned())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
ids,
|
|
["codex-app-server-primary", "codex-app-server-secondary"]
|
|
);
|
|
assert!(
|
|
!ids.iter().any(|id| id.contains('@')),
|
|
"a persisted profile ID must not carry an account identity"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_reading_is_requested_before_the_handshake_completes() {
|
|
let mut session = session();
|
|
let _ = session.initialize("lumbridge", "0.0.1");
|
|
assert!(
|
|
session.poll_frame().is_none(),
|
|
"the app-server requires initialize first"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn one_read_is_outstanding_at_a_time() {
|
|
let mut session = ready();
|
|
assert!(session.poll_frame().is_some());
|
|
assert!(
|
|
session.poll_frame().is_none(),
|
|
"a second read must wait for the first to answer"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_read_becomes_a_provider_reported_observation() {
|
|
let mut session = ready();
|
|
let frame = session.poll_frame().expect("ready sessions poll");
|
|
assert!(frame.contains("account/rateLimits/read"));
|
|
let step = session.on_line(&read_response(2, 25, 300), RESETS_AT_MS - HOUR_MS);
|
|
let SessionStep::Observations(observations) = step else {
|
|
panic!("a full read produces observations");
|
|
};
|
|
assert_eq!(observations.len(), 1, "only primary was present");
|
|
let observation = &observations[0];
|
|
assert_eq!(observation.provenance(), UsageProvenance::ProviderReported);
|
|
assert_eq!(observation.unit(), Some(UsageUnit::WindowPermille));
|
|
assert_eq!(observation.consumed(), 250);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unchanged_reading_is_not_re_recorded_every_poll() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let at = RESETS_AT_MS - HOUR_MS;
|
|
assert!(matches!(
|
|
session.on_line(&read_response(2, 25, 300), at),
|
|
SessionStep::Observations(_)
|
|
));
|
|
let _ = session.poll_frame();
|
|
assert!(
|
|
matches!(
|
|
session.on_line(&read_response(3, 25, 300), at + 60_000),
|
|
SessionStep::Idle
|
|
),
|
|
"an identical reading must not flush the bounded retention"
|
|
);
|
|
let _ = session.poll_frame();
|
|
assert!(
|
|
matches!(
|
|
session.on_line(&read_response(4, 26, 300), at + 120_000),
|
|
SessionStep::Observations(_)
|
|
),
|
|
"a changed reading is always recorded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unchanged_reading_is_re_emitted_after_the_floor_so_a_rate_stays_derivable() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let at = RESETS_AT_MS - 4 * HOUR_MS;
|
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
|
let _ = session.poll_frame();
|
|
assert!(matches!(
|
|
session.on_line(&read_response(3, 25, 300), at + 300_001),
|
|
SessionStep::Observations(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_sparse_push_without_a_window_records_nothing() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let at = RESETS_AT_MS - HOUR_MS;
|
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
|
// Upstream: nullable metadata absent from a rolling update "does not
|
|
// clear a previously observed value".
|
|
let push =
|
|
r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#;
|
|
assert!(
|
|
matches!(session.on_line(push, at + 1_000), SessionStep::Idle),
|
|
"a sparse push must neither clear nor merge"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_read_that_drops_a_window_records_an_explicit_gap() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let at = RESETS_AT_MS - HOUR_MS;
|
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
|
let _ = session.poll_frame();
|
|
let empty =
|
|
r#"{"jsonrpc":"2.0","id":3,"result":{"rateLimits":{"primary":null,"secondary":null}}}"#;
|
|
let SessionStep::Observations(observations) = session.on_line(empty, at + 1_000) else {
|
|
panic!("a full read that loses a window is a change worth recording");
|
|
};
|
|
assert_eq!(observations.len(), 1);
|
|
assert_eq!(observations[0].provenance(), UsageProvenance::Unavailable);
|
|
}
|
|
|
|
#[test]
|
|
fn a_denial_stamps_its_gap_with_now_so_the_ledger_accepts_it() {
|
|
use lumbridge_core::UsageLedger;
|
|
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let at = RESETS_AT_MS - HOUR_MS;
|
|
let SessionStep::Observations(good) = session.on_line(&read_response(2, 25, 300), at)
|
|
else {
|
|
panic!("the first read succeeds");
|
|
};
|
|
let _ = session.poll_frame();
|
|
let denied = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#;
|
|
let SessionStep::Observations(gaps) = session.on_line(denied, at + 60_000) else {
|
|
panic!("a denial is a fact about the account");
|
|
};
|
|
|
|
// The ledger is append-only, so a gap stamped behind the reading it
|
|
// supersedes would be refused and the stale reading would survive.
|
|
let mut ledger = UsageLedger::new();
|
|
for observation in good.into_iter().chain(gaps) {
|
|
ledger
|
|
.record(observation)
|
|
.expect("every observation must be accepted in order");
|
|
}
|
|
let projection = ledger.project(session.profiles()[0].id(), at + 60_000);
|
|
assert!(
|
|
!projection.is_available(),
|
|
"the denial must supersede the earlier reading"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_api_key_account_reports_unavailable_rather_than_a_fault() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#;
|
|
let SessionStep::Observations(observations) = session.on_line(denied, 5_000) else {
|
|
panic!("a quota-less account is a fact about the account");
|
|
};
|
|
assert_eq!(observations.len(), 2);
|
|
assert!(
|
|
observations
|
|
.iter()
|
|
.all(|observation| observation.provenance() == UsageProvenance::Unavailable)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn any_other_rejection_faults_the_probe() {
|
|
let mut session = ready();
|
|
let _ = session.poll_frame();
|
|
let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"boom"}}"#;
|
|
assert!(matches!(
|
|
session.on_line(denied, 5_000),
|
|
SessionStep::Health(ProbeHealth::Faulted(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_credential_refresh_request_is_refused_and_counted() {
|
|
let mut session = ready();
|
|
let step = session.on_line(
|
|
r#"{"jsonrpc":"2.0","id":99,"method":"account/chatgptAuthTokens/refresh"}"#,
|
|
0,
|
|
);
|
|
let SessionStep::Refused { line, class } = step else {
|
|
panic!("a credential request must be refused");
|
|
};
|
|
assert_eq!(class, ServerRequestClass::CredentialRefresh);
|
|
assert!(line.contains("-32601"));
|
|
assert!(
|
|
!line.contains("access"),
|
|
"a refusal must not echo the request"
|
|
);
|
|
assert_eq!(session.refused_credential_requests(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_line_faults_rather_than_being_guessed_at() {
|
|
let mut session = ready();
|
|
assert!(matches!(
|
|
session.on_line("this is not json", 0),
|
|
SessionStep::Health(ProbeHealth::Faulted(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn window_slots_have_distinct_static_identifiers() {
|
|
assert_ne!(
|
|
WindowSlot::Primary.profile_id(),
|
|
WindowSlot::Secondary.profile_id()
|
|
);
|
|
}
|
|
}
|