Commit Graph
16 Commits
Author SHA1 Message Date
vicrodh b4404f45ba fix(qconnect): enforce shared lifecycle authority
cx
2026-08-30 22:22:08 -06:00
vicrodh 74a41150e9 feat(qconnect): add transactional LAN delegation
cx
2026-08-30 15:33:23 -06:00
vicrodh 51e496e47f chore(deps): tokio-tungstenite 0.30
cc
2026-08-29 08:26:25 -06:00
vicrodh 957ce924b3 chore(deps): prost 0.14
cc
2026-08-29 08:24:28 -06:00
vicrodh 48c6f6854d fix(qt): harden remote playback transitions
cx
2026-08-22 15:42:06 -06:00
vicrodh 85a811c834 feat(qconnect): recover from terminal states via idle-retry + startup backoff
Add `reconnect_idle_retry_ms` (default 0 = legacy terminate). When > 0, the
transport loop no longer breaks permanently on Exhausted: the new
`idle_retry_after_exhausted` helper idles (shutdown-cancellable), resets the
attempt counter / backoff to base, and retries (gap #7). The real config
sets 60s. The reset happens ONLY after Exhausted plus a deliberate idle
wait, never on connect, so the issue #358 latch is untouched.

Critical cross-file coupling: `service.rs`'s `MaxReconnectAttemptsExceeded`
handler previously dropped the runtime and broke the event loop. With
idle-retry active the transport stays alive and re-emits events, so the
handler now surfaces lifecycle Exhausted to the UI but keeps the runtime and
continues the loop (only the legacy path still drops + breaks). When the
re-armed transport later reaches SessionEstablished, the existing handler
moves the lifecycle back to Connected.

Startup auto-connect now retries on a bounded, monotonic schedule
(`startup_retry_schedule` = 2s/5s/15s/30s) instead of giving up after one
failed attempt (gap #8), re-resolving the transport config each iteration.

cc
2026-06-01 03:50:18 -06:00
vicrodh 7628c284a6 feat(qconnect): detect half-open WS via keepalive pong-timeout
Track `last_pong_at` and an outstanding-ping count per connection. On each
keepalive tick, before sending the next ping, check `keepalive_is_stale`:
when at least two pings have gone unanswered AND the last pong is older than
~2.5x the ping interval, break the read loop with reason `keepalive_timeout`
(gap #6). This catches a dead TCP path that no read/write has yet surfaced
as an error.

The break falls into the existing increment-only `handle_reconnect_delay`,
so the issue #358 latch is unaffected. The staleness decision is a pure
helper for headless unit testing.

cc
2026-06-01 03:43:13 -06:00
vicrodh 05d641d871 feat(qconnect): surface missing AUTHENTICATE JWT as a hard credential error
Add `require_jwt` (default false) to `WsTransportConfig` and a
`MissingAuthenticateJwt` error variant. When `require_jwt` is set but no
`jwt_qws` is present, the connect attempt now emits a `TransportError`,
closes the socket, and routes through the existing bounded reconnect path
instead of silently skipping the AUTHENTICATE frame (gap #12).

The default stays false so the InMemory / test transport path keeps
working without a JWT; `resolve_transport_config` flips it on for the real
Qobuz endpoint. Reuses the increment-only `handle_reconnect_delay`, so the
issue #358 latch is untouched.

cc
2026-06-01 03:42:09 -06:00
vicrodh d74332514d fix(qconnect-transport-ws): decode MSG_TYPE_ERROR frame and surface code+descr
The MSG_TYPE_ERROR (cloud_type=9) arm previously logged only the payload
byte count, so when Qobuz cloud rejected a session (zombie session,
conflicting device, expired JWT, ...) we had no signal beyond an opaque
"bytes=13" TransportError. That hid the actual cause of the 2s flicker
loop seen after the app sat minimized for 1-2 hours (issue #358).

The qws.proto ErrorMessage (messageTypeId=9) carries:
  uint32 msg_id   = 1
  uint64 msg_date = 2
  uint32 code     = 3
  string descr    = 4

Decode it via a local QwsErrorMessage prost struct, log a warn line with
the decoded code+descr, and emit a new TransportEvent::CloudError so
qconnect-app and qconnect_service can react. qconnect_service forwards
it as a "qconnect:cloud_error" diagnostic (level=warning) and logs it.
qconnect-app treats it as a no-op forwarder for now, matching the
existing pattern for other transport-only events. Decode failures fall
back to the previous TransportError path under a distinct stage name so
they are still visible.

Adds a tokio test that builds a real MSG_TYPE_ERROR frame and asserts
the transport emits CloudError (not the legacy TransportError).

Refs #358

cc
2026-04-25 20:44:46 -06:00
vicrodh ecc27bbcaa fix(qconnect-transport-ws): reset backoff only on SESSION_STATE, cap reconnect attempts
The native WS transport reset its reconnect counters as soon as the WS
upgrade succeeded. Qobuz cloud accepts the WS connection well before the
JOIN_SESSION exchange and rejects unhealthy sessions with MSG_TYPE_ERROR
right after, so the counter reset every cycle and the loop hammered the
endpoint at base backoff (~2s) forever (issue #358).

Move the counter reset to the moment the cloud emits a
MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE event (the actual proof of a healthy
session-level join), and add a configurable maximum-attempts cap that
makes the transport stop and emit MaxReconnectAttemptsExceeded instead
of looping indefinitely.

- Add SessionEstablished and MaxReconnectAttemptsExceeded transport events.
- Add reconnect_max_attempts to WsTransportConfig (default Some(10)).
- handle_incoming_binary now returns whether SESSION_STATE was decoded.
- Reconnect helper returns a ReconnectOutcome enum (Continue/Shutdown/
  Exhausted) instead of a bare bool.
- qconnect_service.rs logs the new events and emits a diagnostic when
  the cap is exceeded so the higher layer can react (#358-fix-3 will
  surface it in the UI).
- Unit tests cover the cap behavior and the unbounded fallback.

Refs #358

cc
2026-04-25 20:01:42 -06:00
vicrodh de29f1e2e0 chore(qconnect): checkpoint current poc state
cx
2026-03-13 17:28:05 -06:00
vicrodh 4c7be7597d fix(qconnect): remove wrong inner envelope, decode QConnectBatch directly
Root cause: inner_payload from CloudPayload.payload IS a QConnectBatch
(QConnectMessages) with repeated QConnectMessage entries in field 3.

The QCloudInnerEnvelope struct incorrectly treated field 3 as opaque
bytes, keeping only the last repeated entry and losing all messages.
Additionally, QConnectMessages.messages_time was declared as uint64
(wire type 0/varint) but the server sends fixed64 (wire type 1),
causing a wire type mismatch that would fail decoding.

Changes:
- Remove QCloudInnerEnvelope struct entirely
- Pass inner_payload directly to decode_queue_server_events/decode_renderer_server_commands
- Fix messages_time from uint64 to fixed64 to match server wire format

Verified against qonductor proto definitions (QConnectBatch) used in
working POC v1 and v2 implementations.
2026-02-21 17:15:34 -06:00
vicrodh 975fac55f7 diag(qconnect): add decode-layer logging to find envelope unwrap issue
Log whether QCloudInnerEnvelope decode succeeds/fails, message byte
count, and whether queue/renderer decoders succeed or error.
2026-02-21 16:27:01 -06:00
vicrodh 309da97b8e fix(qconnect): unwrap inner QCloud envelope and defer renderer join
Root cause: server wraps QConnectMessage inside an inner envelope with
fields 1 (correlation_id), 2 (sequence), 3 (actual message bytes).
We were passing the envelope directly to QConnectMessage::decode(),
which silently skipped the unknown wrapper fields and found nothing.

Also restructure bootstrap: controller join first (works without
session_uuid), renderer join deferred until SESSION_STATE (type 81)
arrives with the session_uuid the server requires.
2026-02-21 16:12:37 -06:00
vicrodh fc0135246a feat(qconnect-app): apply renderer commands from WS transport events
-cx
2026-02-21 12:35:20 -06:00
vicrodh c4ffbf4f57 feat(qconnect-transport): add native websocket transport with qcloud framing
-cx
2026-02-21 12:17:47 -06:00