Skip to content

A starting desktop says so, instead of looking absent - #46

Merged
jpablo merged 4 commits into
viewerfrom
desktop-starting-state
Aug 21, 2026
Merged

A starting desktop says so, instead of looking absent#46
jpablo merged 4 commits into
viewerfrom
desktop-starting-state

Conversation

@jpablo

@jpablo jpablo commented Aug 21, 2026

Copy link
Copy Markdown
Owner

The control socket was bound inside tauri's setup, which runs only after the webview is initialized. On Windows that's routinely 15s and has been measured past 30. For that whole window connect was refused, so ControlChannel returned NoDesktop and gx status said "no desktop is running" about a desktop the user could watch starting.

Refused is indistinguishable from absent. The state a user most wants named was the one state the protocol couldn't express.

The change

The socket is bound at the top of main, before tauri exists, with the accept loop started immediately. connect succeeds from the first millisecond. What the socket can do is then answered per method rather than by blocking:

Method During startup
status Works. It's the method that distinguishes "starting" from "not there", so it must not wait — and it needs no window.
watch / show / put-document / push-text / session Typed STARTING refusal

The rejected alternative matters

Binding early but deferring the accept loop would also make connect succeed — and would then leave the client blocked on a read for the entire webview startup, because ControlChannel.call has no read timeout. A fast, true refusal beats a hang that looks like a fast success.

Mechanics

  • ConnectionContext.app_handle becomes Arc<OnceLock<AppHandle>>, filled by setup; handlers ask through ConnectionContext::app.
  • spawn_control_server splits into bind_control_socket (called in main) and the accept loop. check_socket_path_length still runs before any bind and set_owner_only_permissions immediately after — both verified, the former by tripping it during testing.
  • The runtime file is written after the bind, not before. Writing it first published a path that didn't answer yet — the very state that made a starting desktop look dead. File-exists and socket-exists are now the same fact.
  • StatusBody gains state. running stops being a hardcoded true (it could only ever be read by a client that had already connected, so it said nothing) and now means "there is a window".

Wire compatibility

gx treats an absent state as running: a desktop from before this only ever answered once fully up, so silence means running. Read the other way, every older desktop would report itself as starting forever. The pre-existing status tests stub a body with no state, so they pin that compatibility without being touched.

Verification

End to end against a sandboxed instance (HOME redirected, so the running desktop was untouched), polling the socket every 5ms from launch:

t= 0.001s  state=starting
t= 0.264s  state=running

First answer 1ms after launch, where the old build refused until setup. macOS closes the gap in 264ms; Windows is where the 15–30s version lives, and where the runtime smoke's new socket-timing diagnostics will show it.

Unit tests cover both halves, and the "starting" one was confirmed to fail when the state mapping is deliberately broken. The Rust test is only possible because the handle became a OnceLock — an AppHandle can't be constructed in a unit test, so this state was previously untestable.

Full local suite green: 2178 tests, plus 36 Rust tests.

The control socket was bound inside tauri's `setup`, which runs only
after the webview is initialized. On Windows that is routinely 15s and
has been measured past 30. For that whole window `connect` was refused,
so `ControlChannel` returned NoDesktop and `gx status` said "no desktop
is running" about a desktop the user could watch starting.

Refused is indistinguishable from absent. The state a user most wants
named was the one state the protocol could not express.

The socket is now bound at the top of `main`, before tauri exists, and
the accept loop starts immediately with it. `connect` therefore succeeds
from the first millisecond.

What the socket can DO is a separate question, answered per method rather
than by blocking:

  - `status` needs no window and works at once. It is the method that
    distinguishes "starting" from "not there", so it is exactly the one
    that must not wait.
  - anything needing a window -- watch/show, put-document, push-text, the
    session tier -- gets a typed STARTING refusal.

The rejected alternative matters: binding early but deferring the accept
loop would also make `connect` succeed, and would then leave the client
BLOCKED on a read for the whole webview startup, because
ControlChannel.call has no read timeout. A fast, true refusal beats a
hang that looks like a fast success.

Mechanics:

  - ConnectionContext.app_handle becomes Arc<OnceLock<AppHandle>>, filled
    by `setup`. Handlers ask through ConnectionContext::app.
  - spawn_control_server splits into bind_control_socket (called in main)
    and the accept loop. check_socket_path_length still runs before any
    bind, and set_owner_only_permissions still runs immediately after it
    -- both verified, the former by tripping it during testing.
  - the runtime file is written AFTER the bind, not before. Writing it
    first published a path that did not answer yet, which is the state
    that made a starting desktop look like a dead one. The file existing
    and the socket existing are now the same fact.
  - StatusBody gains `state`: "starting" | "running". `running` stops
    being a hardcoded `true` -- it could only ever be read by a client
    that had already connected, so it said nothing; it now means "there
    is a window".

gx reads `state`, and treats its ABSENCE as running: a desktop from
before this only ever answered once fully up, so silence means running.
Read the other way, every older desktop would report itself as starting
forever. The pre-existing status tests stub a body with no `state`, so
they pin that compatibility without being touched.

Verified end to end against a sandboxed instance (HOME redirected, so the
running desktop was untouched), polling the socket every 5ms from launch:

    t=0.001s  state=starting
    t=0.264s  state=running

First answer 1ms after launch, where the old build refused until setup.
macOS closes the gap in 264ms; Windows is where the 15-30s version of that
gap lives, and where the runtime smoke's new socket-timing diagnostics
will show it.

Unit tests cover both halves, and the "starting" one was confirmed to fail
when the state mapping is broken. The Rust test is only possible because
the handle became a OnceLock -- an AppHandle cannot be constructed in a
unit test, so this state was previously untestable.
@netlify

netlify Bot commented Aug 21, 2026

Copy link
Copy Markdown

Deploy Preview for graph-explorer-net ready!

Name Link
🔨 Latest commit b0a7866
🔍 Latest deploy log https://app.netlify.com/projects/graph-explorer-net/deploys/6a87b3b4b324e20008e3f821
😎 Deploy Preview https://deploy-preview-46--graph-explorer-net.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

jpablo added 3 commits August 20, 2026 18:48
… answers

Caught by the new PR gate on this very branch, which is what it is for:
the macOS runtime smoke waited for the control channel, got an answer,
and then failed one line later on

    assertion failed for release status running: expected 'true', got 'false'

Binding the socket before the webview is the point of this branch, so a
bound socket stopped being proof of a usable desktop — it now answers
`running: false` while starting. `control_ready` only checked that the
call returned "ok", which was indistinguishable from "there is a window"
back when `running` was hardcoded `true`, and became wrong the moment the
field started meaning something.

So every caller of `control_wait_ready` was racing the webview. It did not
show up before because the race was previously unwinnable in the other
direction: nothing answered at all until the window existed.

Worth noting which implementation was right. The Windows gate has always
checked `$status.result.running` in its readiness loop; the shell helper
never did. Two implementations of the same wait, disagreeing, and the
stricter one was correct — the macOS leg is simply the one that reached
the assertion first.

Verified against a real starting desktop in a sandboxed HOME: immediately
after launch `control_ready` returns false, and `control_wait_ready` then
succeeds reporting running=true, state=running.
The Linux leg of the PR gate caught the previous commit:

    FAIL  a live socket is ready: expected [0], got [1]

`control_ready` now requires `running: true`, and the self-test's stub
answered `status` by echoing the request's params — so it replied `{}`,
with no `running` at all. The helper was right and its stub was a desktop
that could not exist.

The stub now answers `status` the way a desktop does, and can be told to
report the state one is in while its window comes up. That turns the
distinction this branch introduces into something assertable with no
build and no desktop, in about a second:

    ok  a live socket is ready
    ok  a starting desktop is not ready
    ok  and it says so
    ok  and it is ready once the window is up

Confirmed to discriminate: with the old, weaker `control_ready` restored,
"a starting desktop is not ready" fails. It pins the behaviour rather
than merely passing alongside it.

The pre-existing "a defaulted params object is valid JSON" check keeps its
point — it exists for the bash 3.2 `${2:-{\}}` bug, and asserts the frame's
CONTENT rather than the call's success, which is what made it catch a bug
that looked like "no desktop". Status now carries a real body, so the
echoed params moved to `.result.echo` and the check reads one level
deeper. Same assertion, same reason.

Three gates have now each caught a different consequence of binding the
socket early, none of which a unit test would have reached: macOS found
the racing wait, Linux found the stub that made the wait look fine, and
the earlier Windows timing found how long the window really takes.
@jpablo
jpablo merged commit 468f2c5 into viewer Aug 21, 2026
13 checks passed
@jpablo
jpablo deleted the desktop-starting-state branch August 21, 2026 02:25
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.

1 participant