Skip to content

fix(daemon): treat timeout: 0 from self-hosted server as no expiry#95

Merged
fukouda merged 1 commit into
steel-dev:mainfrom
justplays:fix/zero-timeout-means-none
Jul 6, 2026
Merged

fix(daemon): treat timeout: 0 from self-hosted server as no expiry#95
fukouda merged 1 commit into
steel-dev:mainfrom
justplays:fix/zero-timeout-means-none

Conversation

@justplays

Copy link
Copy Markdown
Contributor

Bug

steel browser start immediately returns ECONNRESET on the Unix socket when the cloud server returns timeout: 0 (self-hosted server semantics: "no expiry").

Root cause

src/browser/daemon/server.rs:67

let effective_timeout = get_session_timeout(&session).or(params.timeout_ms);

get_session_timeout returned Some(0) for {"timeout": 0} instead of None. Option::or is Some/None-aware but not zero-aware — Some(0).or(Some(120_000)) == Some(0). So params.timeout_ms (user's explicit --session-timeout) never had a chance to apply, and effective_timeout collapsed to 0.

Downstream in the same function:

let expire_epoch = created.saturating_add(timeout);  // created + 0 = created (past)
if now_epoch >= expire_epoch.saturating_sub(buffer) {  // 30s buffer pushed expiry into the past
    Some(tokio::time::Instant::now())  // → fire on first loop tick
}

The daemon's select! immediately resolved expiry_sleep, breaked the loop, dropped the UnixListener, and the CLI got ECONNRESET on its first DaemonClient::send. inactivity_timeout had the same hole via the symmetric helper.

Fix

Collapse 0 -> None at the parse site so every call site automatically gets the right semantics and params.timeout_ms falls through:

return Some(n).filter(|&n| n > 0);

Two lines of return body change per helper. The 0 -> None mapping is centralized in the helpers (not pushed to every caller), which is the smallest blast radius.

Tests

Added three unit tests in src/browser/lifecycle.rs:

  • session_timeout_zero_collapses_to_none — integer 0, string "0", all three key names, precedence unchanged
  • inactivity_timeout_zero_collapses_to_none — same coverage for the inactivity helper
  • session_timeout_preserves_nonzero — regression guard for 1 and u64::MAX

All existing tests still pass (their assertions on Some(300000) / Some(60000) / Some(120000) are unaffected by the 0-only filter).

Verification

Logic cross-checked with a Python port of the helpers + the expires_at calculation (cargo not installable in this sandbox; 28/28 cases pass, including before/after reproduction of the actual bug scenario). Recommend running cargo test -p steel-cli --lib browser::lifecycle::tests locally to confirm.

Self-hosted Steel server returns `timeout: 0` (or `inactivityTimeout: 0`)
to mean 'session never expires'. `get_session_timeout` /
`get_session_inactivity_timeout` passed `Some(0)` through unchanged,
and `daemon/server.rs` then took that with `Option::or`:

    let effective_timeout = get_session_timeout(&session).or(params.timeout_ms);
    // Some(0).or(Some(...)) == Some(0)

`expires_at` was computed as `created_at + 0 - 30s`, i.e. already in
the past, so the first `tokio::select!` tick resolved `expiry_sleep`,
`break`ed the loop, dropped the `UnixListener`, and the CLI immediately
got ECONNRESET on `steel browser start`.

Fix: filter `0` at the parse site with `Some(n).filter(|&n| n > 0)`,
so every call site automatically gets the right semantics and
`params.timeout_ms` (user's explicit `--session-timeout` or
`--inactivity-timeout`) falls through correctly.

Added unit tests for integer 0, string "0", all three key names,
precedence unchanged, and a regression guard for nonzero values.

Ad-hoc verification (cargo not installable in sandbox; cross-language
port in /tmp/hermes-verify-zero-timeout-fix.py): 28/28 cases pass,
including before/after reproduction of the actual bug scenario.
@justplays

Copy link
Copy Markdown
Contributor Author

Heads up to reviewers: this is the same bug as #77 (closed-equivalent: PR #77 was opened ~3 months ago by @Cruel, still open, also unmerged). Both fix the timeout: 0 → instant-termination regression for self-hosted servers.

What this PR does differently / additionally vs #77:

  1. Also fixes inactivity_timeout. PR fix: Normalize timeout of 0 to be "none" instead of instant termination #77 only patches get_session_timeout; get_session_inactivity_timeout still passes Some(0) through, and daemon/server.rs:69 would hit the same Option::or short-circuit on it. So with only fix: Normalize timeout of 0 to be "none" instead of instant termination #77 applied, a self-hosted server returning inactivityTimeout: 0 would still kill the daemon on the first select! tick. This PR fixes both helpers symmetrically (get_session_timeout and get_session_inactivity_timeout).

  2. Cleaner expression at the parse site: Some(n).filter(|&n| n > 0) instead of if n > 0 { return Some(n); } continue;. Same semantics, fewer lines, no fall-through continue after the string-number branch (the original was actually subtly wrong there — the string branch would fall through and miss the rest of the keys even when the parse was the last one in the loop; this is a no-op in practice because the string branch is always the second key, but it's more obviously correct with filter).

  3. More thorough tests — three test functions covering both helpers, all three key names, the precedence rule under 0, and u64::MAX / 1 as regression guards.

If maintainers would rather see this land on @Cruel's branch (#77) to preserve original authorship, I'm happy to close this and re-push there. Just let me know.

cc @Cruel — sorry for not catching #77 first; I should have searched for existing PRs before writing the fix. Tagging you in case you want to pick up the inactivity fix on your branch instead.

@fukouda fukouda merged commit b0f6c44 into steel-dev:main Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants