From b314cfc5a68734ede081bb4012c5ac9d2f346b61 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 15 Sep 2026 20:27:04 +0530 Subject: [PATCH] objects/uts: observe transient connection states via record-and-verify; pin fresh-channel pipeline readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three derived-test fixes surfaced by real CI failures in ably-java, all of the same class: pseudocode steps whose faithful rendering is racy on an asynchronous SDK. - objects_faults.md: both scenarios awaited DISCONNECTED with AWAIT_STATE *after* the disconnect stimulus. DISCONNECTED after a drop from CONNECTED is transient at microsecond scale (RTN15a queues the reconnect before the DISCONNECTED emit), and AWAIT_STATE is level-triggered — the transition can fire and be superseded before the waiter registers, which the corpus itself already forbids (writing-test-specs.md, "Verifying Transient States"). Replace both with the record-and-verify pattern: recording registered before the stimulus, a poll_until gate on the recorded list where the wait is load-bearing, a sticky AWAIT_STATE CONNECTED for the final wait, and a CONTAINS_IN_ORDER assert for the observation-only scenario. Comments state the register-before-stimulus ordering as load-bearing so a translator who reorders can see the breakage. - realtime_object.md (RTO17-RTO18, "initial attach"): the fresh-channel scenario had no process_pending_events() between channels.get() and attach(), so an SDK whose objects message pipeline initializes asynchronously can drop the OBJECT_SYNC delivered by a synchronous mock. Add the step, matching the existing per-site convention. - writing-derived-tests.md: two mapping-table additions — a transient-state caveat on the AWAIT_STATE row (never await post-stimulus; use record-and-verify) and a recording-lists row (appended from SDK callback threads, read by poll_until from the test thread: multithreaded SDKs must render them as thread-safe lists). --- uts/docs/writing-derived-tests.md | 3 +- .../integration/proxy/objects_faults.md | 34 ++++++++++++++----- uts/objects/unit/realtime_object.md | 3 ++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/uts/docs/writing-derived-tests.md b/uts/docs/writing-derived-tests.md index e5dd1f321..8f5953cc8 100644 --- a/uts/docs/writing-derived-tests.md +++ b/uts/docs/writing-derived-tests.md @@ -38,8 +38,9 @@ UTS specs use generic pseudocode. You need to map this onto the SDK's actual API | `install_mock(mock_http)` | How mocks are injected (DI, platform patching, etc.) | | `enable_fake_timers()` | Timer control mechanism | | `ADVANCE_TIME(ms)` | Fake timer tick method | -| `AWAIT_STATE(connection, "connected")` | State waiting helper | +| `AWAIT_STATE(connection, "connected")` | State waiting helper. Transient states (DISCONNECTED/SUSPENDED after a drop): never await post-stimulus — use the record-and-verify pattern (writing-test-specs.md, "Verifying Transient States"). | | `poll_until(condition, ...)` | Shared polling helper (wall-clock deadline — see below) | +| `state_changes = []` / `events = []` (recording lists) | Event-recording collection for the record-and-verify pattern. Appended from SDK callback threads while `poll_until`/final asserts read it from the test thread — multithreaded SDKs must render it as a thread-safe list. | | `poll_until_success(condition)` | Error-tolerant polling helper (see the pseudocode conventions in `uts/README.md`) | Check the SDK's existing test infrastructure and conventions before writing anything. Reuse existing helpers, mock classes, and patterns. diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md index a1eecd451..11044b21d 100644 --- a/uts/objects/integration/proxy/objects_faults.md +++ b/uts/objects/integration/proxy/objects_faults.md @@ -112,18 +112,27 @@ channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJEC ### Test Steps ```pseudo +// Record connection states BEFORE the disconnect stimulus (channel.attach(), below). +// DISCONNECTED is transient (RTN15a reconnects immediately); a post-stimulus AWAIT_STATE +// can miss it, so this listener MUST precede the stimulus — reordering breaks the test. +// (see docs/writing-test-specs.md, "Verifying Transient States") +state_changes = [] +client.connection.on((change) => { state_changes.append(change.current) }) + client.connect() AWAIT_STATE client.connection.state == CONNECTED WITH timeout: 15 seconds -// First attach triggers sync; proxy disconnects mid-sync +// First attach triggers sync; proxy disconnects mid-sync, then the client auto-reconnects. channel.attach() -AWAIT_STATE client.connection.state == DISCONNECTED - WITH timeout: 15 seconds - -// Client auto-reconnects; re-attach triggers fresh sync +// Gate on the RECORDED list before the final wait: the drop lands only after the sync frame +// round-trips, so an immediate AWAIT_STATE CONNECTED would no-op (still connected) and race it. +poll_until(state_changes CONTAINS DISCONNECTED, timeout: 30s) +// Final wait targets CONNECTED, a sticky state — safe for AWAIT_STATE. AWAIT_STATE client.connection.state == CONNECTED WITH timeout: 30 seconds +// CONTAINS_IN_ORDER is a subsequence match, so the leading initial-connect states are fine. +ASSERT state_changes CONTAINS_IN_ORDER [DISCONNECTED, CONNECTING, CONNECTED] // get() waits for SYNCED — will only resolve if re-sync completes root = AWAIT channel.object.get() @@ -200,12 +209,21 @@ root_b = AWAIT channel_b.object.get() WITH timeout: 15 seconds poll_until_success(root_b.get("key1").value() == "initial") +// Record B's connection states BEFORE the disconnect stimulus (trigger_action, below). +// DISCONNECTED is transient (RTN15a reconnects immediately); a post-stimulus AWAIT_STATE +// can miss it, so this listener MUST precede the stimulus — reordering breaks the test. +// (see docs/writing-test-specs.md, "Verifying Transient States") +state_changes = [] +client_b.connection.on((change) => { state_changes.append(change.current) }) + // Disconnect client B session.trigger_action({ type: "disconnect" }) -AWAIT_STATE client_b.connection.state == DISCONNECTED - WITH timeout: 15 seconds +// Mid-test gate: poll the RECORDED list (not AWAIT_STATE on live state, which could miss +// the transient DISCONNECTED). This blocks A's publish until B has observed the drop. +poll_until(state_changes CONTAINS DISCONNECTED, timeout: 15s) -// While B is disconnected, A publishes a mutation +// A publishes while B is down. Best-effort: RTN15a may reconnect/re-sync B before this +// round-trips (then it tests plain delivery, not RTO7/RTO8); the final poll tolerates both. AWAIT root_a.set("key1", "updated_during_disconnect") // Client B reconnects and re-syncs; the mutation should be visible diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index ed2bcd512..02966d3e5 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1799,6 +1799,9 @@ scenarios = [ install_mock(mock_ws) client = Realtime(options: { key: "fake:key", autoConnect: true }) channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + // Let the fresh channel's objects message pipeline finish subscribing before attach() + // (see process_pending_events in uts/README.md) + process_pending_events() // NOTE: channel is NOT yet attached/synced here — listeners must be wired // by the loop before scenario.trigger() calls attach(). RETURN { client, channel, mock_ws }