Skip to content

fix(sdk-js): fail overdue writes as outcome-unknown when a dead server's connection is dropped - #363

Merged
jsam merged 4 commits into
mainfrom
fm/fix-js-flake
Oct 5, 2026
Merged

jsam merged 4 commits into
mainfrom
fm/fix-js-flake

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Intent

I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. ... just ensure that both machines are propelry utilized now (Context: implement #351 exactly as written.)

Issue #351, "JS SDK: dead_server connection fixture flakes (OutcomeUnknownError vs ConnectionLostError)": found by the test-strategy review on main edb8bc3, 1 of 3 re-runs fails. The JS SDK unit test connection fixtures > dead_server expects OutcomeUnknownError, gets ConnectionLostError. The fixture (packages/inputlayer-js/tests/fixtures/connection/dead_server.json, from #237) has the mock server accept one write then stall; the SDK must fail the write as outcome-unknown at its deadline, but sometimes the connection teardown wins and reports connection-lost (timing-dependent). Unreported; the JS unit suite runs only in the full suite, so no PR sees it. Fix the race in the mock or the SDK, add it to the conformance set, and run the JS suite on PRs (T-05).

What Changed

  • JS SDK race fix (connection.ts). When the transport probe gets no answer and the client drops the connection, any call already past its deadline (graced) now fails with the overdue error: OutcomeUnknownError for a call that may write, DeadlineExceededError for a query. It no longer fails with ConnectionLostError. Calls that are not yet overdue still fail with ConnectionLostError. The probe also now starts only after the call has been marked graced. This removes the timing race behind the flaky dead_server fixture.
  • New dead_server conformance fixture. Added packages/conformance/connection/dead_server.json, and the conformance format gains three things: an options.deadline_grace_ms setting, a per-call timeout_ms, and a {"stall": true} server step that stops reading, like a dead server. The Python conformance runner supports all three, and the JS connection tests are updated to match. The js-sdk guide, the conformance README and the source comments describe the new failure behavior.
  • JS SDK tests on PRs. CI has a new JS SDK job that runs make pre-pr-js (unit tests and type check) when packages/inputlayer-js/, packages/conformance/ or the workflow file changes. CONTRIBUTING now lists the SDK jobs under the fast gate.

Risk Assessment

✅ Low: The change is small and bounded to the JS SDK's deadline and probe path. Calls in grace now fail the same way whichever timer fires first, which fixes the race in #351. It adds a regression test that fails without the fix, adds the conformance fixture (the Python SDK handles it deterministically because it has no deadline probe), and adds a path-filtered JS CI job. The one remaining issue is the wording of an error message.

Testing

I ran the JS SDK against a real inputlayer-server frozen with SIGSTOP, using both the fixed and the base SDK. On the fixed SDK, every call past its deadline failed the overdue way: OutcomeUnknownError for a write, DeadlineExceededError for a query. The call still inside its deadline got ConnectionLostError. That held in all 5 rounds. The base SDK misclassified the staggered write and the query as ConnectionLostError in all 5 rounds. Non-live supporting checks: the targeted JS tests fail on base and passed 25/25 on HEAD, the Python dead_server conformance fixture passes, and make pre-pr-js, which the new CI job runs, passes. Those ran against mock servers or locally, so their scenarios are marked untested. The GitHub Actions job itself can only run on GitHub, so I checked its command locally and parsed its config instead. I deleted the build outputs and Python caches afterward.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A write on a dead (SIGSTOPped) real server fails with OutcomeUnknownError at deadline+grace, not ConnectionLostError ✅ pass live live-sigstop-head.json: write A -> OutcomeUnknownError(outcome_unknown) @~500ms in 5/5 rounds
Adversarial: a second write whose deadline falls inside the first write's probe window also fails with OutcomeUnknownError when the probe drops the connection ✅ pass live live-sigstop-head.json: write B -> OutcomeUnknownError in 5/5 rounds; base SDK gives ConnectionLostError in 5/5 (live-sigstop-base.json)
A query past its deadline on a dead server fails with DeadlineExceededError when the probe drops the connection ✅ pass live live-sigstop-head.json: query R -> DeadlineExceededError in 5/5 rounds (base: ConnectionLostError)
Guard: a call not yet past its deadline still fails with ConnectionLostError when the probe drops the connection ✅ pass live live-sigstop-head.json: write L (60 s deadline) -> ConnectionLostError(closed) in 5/5 rounds
The flaky dead_server fixture and the new probe tests are deterministic on the fix and fail on the base SDK ⏸️ untested no The earlier payload recorded this only from the vitest unit/mock-server suite (25/25 on HEAD, 3 failures on base, in head-repeat-runs.txt and base-sdk-with-new-tests.txt). That is not a live product r…
dead_server is in the shared conformance set and the Python SDK passes it ⏸️ untested no The earlier payload recorded this only from the Python conformance harness (py-conformance.txt: dead_server PASSED). That harness drives the SDK against its own scripted mock server, not the real serv…
The JS SDK unit suite runs on PRs through the new CI js-sdk job ⏸️ untested no The workflow only runs on GitHub Actions for a pull request, so it cannot be triggered from this test phase; the PR/CI phase owns it. I ran the job's exact command (make pre-pr-js, passes locally) a…
Evidence: Live SIGSTOP dead-server results, fixed vs base SDK (summary)

fixed: A OutcomeUnknown | B OutcomeUnknown | R DeadlineExceeded | L ConnectionLost (5/5 rounds) base: A OutcomeUnknown | B ConnectionLost | R ConnectionLost | L ConnectionLost (5/5 rounds)

== SDK fm/fix-js-flake (2ab41518)
 round 1 write A timeoutMs=200 -> OutcomeUnknownError @501ms | write B timeoutMs=210 -> OutcomeUnknownError @502ms | query R timeoutMs=205 -> DeadlineExceededError @502ms | write L timeoutMs=60000 -> ConnectionLostError @503ms
 round 2 write A timeoutMs=200 -> OutcomeUnknownError @500ms | write B timeoutMs=210 -> OutcomeUnknownError @500ms | query R timeoutMs=205 -> DeadlineExceededError @500ms | write L timeoutMs=60000 -> ConnectionLostError @501ms
 round 3 write A timeoutMs=200 -> OutcomeUnknownError @502ms | write B timeoutMs=210 -> OutcomeUnknownError @502ms | query R timeoutMs=205 -> DeadlineExceededError @502ms | write L timeoutMs=60000 -> ConnectionLostError @502ms
 round 4 write A timeoutMs=200 -> OutcomeUnknownError @502ms | write B timeoutMs=210 -> OutcomeUnknownError @502ms | query R timeoutMs=205 -> DeadlineExceededError @502ms | write L timeoutMs=60000 -> ConnectionLostError @502ms
 round 5 write A timeoutMs=200 -> OutcomeUnknownError @502ms | write B timeoutMs=210 -> OutcomeUnknownError @502ms | query R timeoutMs=205 -> DeadlineExceededError @502ms | write L timeoutMs=60000 -> ConnectionLostError @502ms
== SDK base (ff6641af)
 round 1 write A timeoutMs=200 -> OutcomeUnknownError @503ms | write B timeoutMs=210 -> ConnectionLostError @503ms | query R timeoutMs=205 -> ConnectionLostError @503ms | write L timeoutMs=60000 -> ConnectionLostError @503ms
 round 2 write A timeoutMs=200 -> OutcomeUnknownError @501ms | write B timeoutMs=210 -> ConnectionLostError @501ms | query R timeoutMs=205 -> ConnectionLostError @501ms | write L timeoutMs=60000 -> ConnectionLostError @501ms
 round 3 write A timeoutMs=200 -> OutcomeUnknownError @500ms | write B timeoutMs=210 -> ConnectionLostError @501ms | query R timeoutMs=205 -> ConnectionLostError @501ms | write L timeoutMs=60000 -> ConnectionLostError @501ms
 round 4 write A timeoutMs=200 -> OutcomeUnknownError @500ms | write B timeoutMs=210 -> ConnectionLostError @500ms | query R timeoutMs=205 -> ConnectionLostError @500ms | write L timeoutMs=60000 -> ConnectionLostError @500ms
 round 5 write A timeoutMs=200 -> OutcomeUnknownError @500ms | write B timeoutMs=210 -> ConnectionLostError @501ms | query R timeoutMs=205 -> ConnectionLostError @501ms | write L timeoutMs=60000 -> ConnectionLostError @501ms
Evidence: Live run raw JSON (fixed SDK)
[
 {
  "round": 1,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 501
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "DeadlineExceededError",
    "code": "deadline_exceeded",
    "ms": 502
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 503
   }
  ]
 },
 {
  "round": 2,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 500
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 500
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "DeadlineExceededError",
    "code": "deadline_exceeded",
    "ms": 500
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   }
  ]
 },
 {
  "round": 3,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "DeadlineExceededError",
    "code": "deadline_exceeded",
    "ms": 502
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 502
   }
  ]
 },
 {
  "round": 4,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "DeadlineExceededError",
    "code": "deadline_exceeded",
    "ms": 502
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 502
   }
  ]
 },
 {
  "round": 5,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 502
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "DeadlineExceededError",
    "code": "deadline_exceeded",
    "ms": 502
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 502
   }
  ]
 }
]
Evidence: Live run raw JSON (base SDK)
[
 {
  "round": 1,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 503
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 503
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 503
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 503
   }
  ]
 },
 {
  "round": 2,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 501
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   }
  ]
 },
 {
  "round": 3,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 500
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   }
  ]
 },
 {
  "round": 4,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 500
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 500
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 500
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 500
   }
  ]
 },
 {
  "round": 5,
  "warmRows": 1,
  "results": [
   {
    "label": "write A timeoutMs=200 (starts the probe)",
    "outcome": "OutcomeUnknownError",
    "code": "outcome_unknown",
    "ms": 500
   },
   {
    "label": "write B timeoutMs=210 (deadline inside A's probe window)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "query R timeoutMs=205",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   },
   {
    "label": "write L timeoutMs=60000 (not yet past its deadline)",
    "outcome": "ConnectionLostError",
    "code": "closed",
    "ms": 501
   }
  ]
 }
]
Evidence: Live driver script
// Live check of #351: a real inputlayer-server is frozen with SIGSTOP (it reads
// nothing and answers no WebSocket ping) while JS SDK calls are in flight.
const { Connection } = require(process.argv[2]);
const pid = Number(process.env.SERVER_PID);
const url = process.env.IL_URL;
const t0 = () => Date.now();
async function round(i) {
  const c = new Connection({ url, username: 'admin', password: process.env.IL_PW,
    autoReconnect: false, timeoutGraceMs: 300, keepaliveMs: 0 });
  await c.connect();
  const warm = await c.execute('+warm(' + i + ')', { timeoutMs: 5000 });
  process.kill(pid, 'SIGSTOP');
  const start = t0();
  const tag = (label, p) => p.then(
    (r) => ({ label, outcome: 'resolved', ms: t0() - start }),
    (e) => ({ label, outcome: e.constructor.name, code: e.code, ms: t0() - start }));
  const calls = [
    tag('write A timeoutMs=200 (starts the probe)', c.execute('+a(' + i + ')', { timeoutMs: 200 })),
    tag('write B timeoutMs=210 (deadline inside A\'s probe window)', c.execute('+b(' + i + ')', { timeoutMs: 210 })),
    tag('query R timeoutMs=205', c.read([{ name: 'r', query: '?warm(X)' }], { timeoutMs: 205 })),
    tag('write L timeoutMs=60000 (not yet past its deadline)', c.execute('+l(' + i + ')', { timeoutMs: 60000 })),
  ];
  const results = await Promise.all(calls);
  process.kill(pid, 'SIGCONT');
  await c.close().catch(() => {});
  return { round: i, warmRows: warm.rows?.length ?? 'ok', results };
}
(async () => {
  const n = Number(process.env.ROUNDS || 5);
  const out = [];
  for (let i = 1; i <= n; i++) out.push(await round(i));
  console.log(JSON.stringify(out, null, 1));
})().catch((e) => { try { process.kill(pid, 'SIGCONT'); } catch {} console.error(e); process.exit(1); });
Evidence: New tests fail against base SDK (regression reproduced)

 RUN  v3.2.7 ~/.no-mistakes/worktrees/9eea230c3aae/01M46H7RECA78M61TK3ZQ7CRR9/packages/inputlayer-js

 ↓ tests/connection.test.ts > connection fixtures > cover the adversarial cases
 ↓ tests/connection.test.ts > connection fixtures > close_mid_stream
 ↓ tests/connection.test.ts > connection fixtures > closing_notice
 ↓ tests/connection.test.ts > connection fixtures > counts_do_not_add_up
 × tests/connection.test.ts > connection fixtures > dead_server 177ms
   → call 0: expected 'ConnectionLostError' to be 'OutcomeUnknownError' // Object.is equality
 ↓ tests/connection.test.ts > connection fixtures > deadline_cancel
 ↓ tests/connection.test.ts > connection fixtures > deadline_cancel_wins
 ↓ tests/connection.test.ts > connection fixtures > deadline_too_late_write
 ↓ tests/connection.test.ts > connection fixtures > duplicate_id_rejection
 ↓ tests/connection.test.ts > connection fixtures > duplicate_notification
 ↓ tests/connection.test.ts > connection fixtures > end_without_start
 ↓ tests/connection.test.ts > connection fixtures > gap_notice_then_close
 ↓ tests/connection.test.ts > connection fixtures > group_subscribe_streamed
 ↓ tests/connection.test.ts > connection fixtures > in_flight_bound
 ↓ tests/connection.test.ts > connection fixtures > interleaved_streams
 ↓ tests/connection.test.ts > connection fixtures > malformed_frames
 ↓ tests/connection.test.ts > connection fixtures > missing_chunk
 ↓ tests/connection.test.ts > connection fixtures > out_of_order_replies
 ↓ tests/connection.test.ts > connection fixtures > overdue_stream_completes
 ↓ tests/connection.test.ts > connection fixtures > overdue_stream_stalls
 ↓ tests/connection.test.ts > connection fixtures > queued_call_deadline
 ↓ tests/connection.test.ts > connection fixtures > rate_limited_once
 ↓ tests/connection.test.ts > connection fixtures > rate_limited_twice
 ↓ tests/connection.test.ts > connection fixtures > repeated_chunk
 ↓ tests/connection.test.ts > connection fixtures > replay_gap_keeps_connection
 ↓ tests/connection.test.ts > connection fixtures > reply_for_unknown_id
 ↓ tests/connection.test.ts > connection fixtures > silent_server_query
 ↓ tests/connection.test.ts > connection fixtures > silent_server_write
 ↓ tests/connection.test.ts > connection fixtures > snapshot_chunk_out_of_order
 ↓ tests/connection.test.ts > connection fixtures > snapshot_error_mid_stream
 ↓ tests/connection.test.ts > connection fixtures > snapshot_for_other_queries
 ↓ tests/connection.test.ts > connection fixtures > snapshot_read_deadline_cancel
 ↓ tests/connection.test.ts > connection fixtures > snapshot_rows_do_not_add_up
 ↓ tests/connection.test.ts > connection fixtures > snapshot_streams_interleaved
 ↓ tests/connection.test.ts > params > sends values beside the program, and omits empty params
 ↓ tests/connection.test.ts > params > refuses params on an engine older than protocol 4, sending nothing
 ↓ tests/connection.test.ts > params > refuses reads and subscription groups on an engine older than protocol 5, sending nothing
 ↓ tests/connection.test.ts > routing > every request carries a distinct id
 ↓ tests/connection.test.ts > routing > subscription pushes reach their route only for the current generation
 ↓ tests/connection.test.ts > routing > delivers notifications while no call is in flight
 ✓ tests/connection.test.ts > deadline probe > a full request window is not taken for a dead server 255ms
 ✓ tests/connection.test.ts > deadline probe > any frame from the server answers the probe 255ms
 × tests/connection.test.ts > deadline probe > a write on a dead server fails outcome-unknown before the probe drops the connection 156ms
   → expected ConnectionLostError: Connection lost: The… { …(3) } to be an instance of OutcomeUnknownError
 × tests/connection.test.ts > deadline probe > every write past its deadline on a dead server fails outcome-unknown when the probe drops it 153ms
   → expected ConnectionLostError: Connection lost: The… { …(3) } to be an instance of OutcomeUnknownError
 ✓ tests/connection.test.ts > deadline probe > calls failed locally on a server that never replies do not block new calls 103ms
 ↓ tests/connection.test.ts > keepalive > pings a connection that sends nothing
 ↓ tests/connection.test.ts > keepalive > a long call past the keepalive interval keeps a live connection
 ↓ tests/connection.test.ts > keepalive > a half-open socket is dropped and reconnected, failing calls in flight
 ↓ tests/connection.test.ts > cancellation > aborting a queued call rejects it without sending it
 ↓ tests/connection.test.ts > cancellation > aborting a sent call sends cancel and fails with the engine reply
 ↓ tests/connection.test.ts > cancellation > a call on a closed connection is refused
 ↓ tests/connection.test.ts > cancellation > every call failed by a close carries its own program
 ↓ tests/connection.test.ts > cancellation > a queued call is sent with what is left of its deadline
 ↓ tests/connection.test.ts > cancellation > close fails calls in flight with ConnectionLostError
 ↓ tests/connection.test.ts > cancellation > a close during authentication ends the open at once
 ↓ tests/connection.test.ts > cancellation > a close before the socket opens leaves the connection closed
 ↓ tests/connection.test.ts > cancellation > an auth_error fails the connect
 ↓ tests/connection.test.ts > cancellation > a close during authentication is transient, not an auth failure
 ↓ tests/connection.test.ts > reconnect > re-opens on the same graph with the notification cursor and resends queued calls
 ↓ tests/connection.test.ts > reconnect > connect during a reconnect waits for it instead of opening its own
 ↓ tests/connection.test.ts > reconnect > a closed connection does not reopen
 ↓ tests/connection.test.ts > reconnect > a reconnect with no notification cursor announces a notification gap
 ↓ tests/connection.test.ts > reconnect > a handle call during a reconnect fails at its deadline
 ↓ tests/connection.test.ts > reconnect > a new engine run drops the old cursor
 ↓ tests/connection.test.ts > reconnect > gives up after maxReconnectAttempts and fails what waits
 ↓ tests/connection.test.ts > reconnect > a lazy connection opens on its first call

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/connection.test.ts > connection fixtures > dead_server
AssertionError: call 0: expected 'ConnectionLostError' to be 'OutcomeUnknownError' // Object.is equality

Expected: �[32m"�[7mOutcomeUnknown�[27mError"�[39m
Received: �[31m"�[7mConnectionLost�[27mError"�[39m

 ❯ tests/connection.test.ts:322:38
    320|         return;
    321|       }
    322|       expect(got.error, `call ${i}`).toBe(want.error);
       |                                      ^
    323|       const cls = (sdk as Record<string, unknown>)[want.error!] as new…
    324|       expect(got.instance).toBeInstanceOf(cls);
 ❯ tests/connection.test.ts:312:26

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯

 FAIL  tests/connection.test.ts > deadline probe > a write on a dead server fails outcome-unknown before the probe drops the connection
AssertionError: expected ConnectionLostError: Connection lost: The… { …(3) } to be an instance of OutcomeUnknownError
 ❯ tests/connection.test.ts:509:21
    507|       stall(server!.last);
    508|       const error = await call;
    509|       expect(error).toBeInstanceOf(sdk.OutcomeUnknownError);
       |                     ^
    510|       expect((error as sdk.OutcomeUnknownError).code).toBe('outcome_un…
    511|     } finally {

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯

 FAIL  tests/connection.test.ts > deadline probe > every write past its deadline on a dead server fails outcome-unknown when the probe drops it
AssertionError: expected ConnectionLostError: Connection lost: The… { …(3) } to be an instance of OutcomeUnknownError
 ❯ tests/connection.test.ts:524:21
    522|     stall(server!.last);
    523|     for (const error of await Promise.all(calls)) {
    524|       expect(error).toBeInstanceOf(sdk.OutcomeUnknownError);
       |                     ^
    525|       expect((error as sdk.OutcomeUnknownError).code).toBe('outcome_un…
    526|     }

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯


 Test Files  1 failed (1)
      Tests  3 failed | 3 passed | 60 skipped (66)
   Start at  17:41:41
   Duration  2.24s (transform 386ms, setup 0ms, collect 706ms, tests 1.10s, environment 0ms, prepare 192ms)
Evidence: 25 repeated targeted runs on HEAD
HEAD: 25 runs of dead_server fixture + deadline probe tests: pass=25 fail=0
Evidence: Python conformance run incl. dead_server
Using CPython 3.10.22
Creating virtual environment at: .venv
Installed 63 packages in 3.07s
============================= test session starts ==============================
platform linux -- Python 3.10.22, pytest-9.1.1, pluggy-1.6.0 -- ~/.no-mistakes/worktrees/9eea230c3aae/01M46H7RECA78M61TK3ZQ7CRR9/packages/inputlayer-py/.venv/bin/python
cachedir: .pytest_cache
rootdir: ~/.no-mistakes/worktrees/9eea230c3aae/01M46H7RECA78M61TK3ZQ7CRR9/packages/inputlayer-py
configfile: pyproject.toml
plugins: langsmith-0.12.1, asyncio-1.3.0, anyio-4.13.0
asyncio: mode=auto, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 25 items

tests/test_connection_conformance.py::test_connection_fixture[chunked_result] PASSED [  4%]
tests/test_connection_conformance.py::test_connection_fixture[chunked_result_count_mismatch] PASSED [  8%]
tests/test_connection_conformance.py::test_connection_fixture[chunked_result_missing_chunk] PASSED [ 12%]
tests/test_connection_conformance.py::test_connection_fixture[close_mid_query] PASSED [ 16%]
tests/test_connection_conformance.py::test_connection_fixture[close_mid_stream_write] PASSED [ 20%]
tests/test_connection_conformance.py::test_connection_fixture[closing_notice] PASSED [ 24%]
tests/test_connection_conformance.py::test_connection_fixture[dead_server] PASSED [ 28%]
tests/test_connection_conformance.py::test_connection_fixture[duplicate_id_rejection] PASSED [ 32%]
tests/test_connection_conformance.py::test_connection_fixture[error_codes_are_typed] PASSED [ 36%]
tests/test_connection_conformance.py::test_connection_fixture[group_subscribe_routes] PASSED [ 40%]
tests/test_connection_conformance.py::test_connection_fixture[notification_before_reply] PASSED [ 44%]
tests/test_connection_conformance.py::test_connection_fixture[notification_while_idle] PASSED [ 48%]
tests/test_connection_conformance.py::test_connection_fixture[rate_limited_retried_once] PASSED [ 52%]
tests/test_connection_conformance.py::test_connection_fixture[read_deadline_exceeded] PASSED [ 56%]
tests/test_connection_conformance.py::test_connection_fixture[read_error_names_query] PASSED [ 60%]
tests/test_connection_conformance.py::test_connection_fixture[replay_gap] PASSED [ 64%]
tests/test_connection_conformance.py::test_connection_fixture[replies_routed_by_id] PASSED [ 68%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_error_mid_stream] PASSED [ 72%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_read] PASSED [ 76%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_streamed] PASSED [ 80%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_streamed_count_mismatch] PASSED [ 84%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_streamed_missing_chunk] PASSED [ 88%]
tests/test_connection_conformance.py::test_connection_fixture[snapshot_streamed_result_order] PASSED [ 92%]
tests/test_connection_conformance.py::test_connection_fixture[stale_generation] PASSED [ 96%]
tests/test_connection_conformance.py::test_connection_fixture[statement_errors] PASSED [100%]

============================== 25 passed in 3.37s ==============================
Evidence: make pre-pr-js (CI job command)
cd packages/inputlayer-js && npm ci --ignore-scripts && npm test

added 87 packages, and audited 88 packages in 7s

19 packages are looking for funding
  run `npm fund` for details

2 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

> [email protected] test
> vitest run --exclude 'tests/*integration.test.ts'


 RUN  v3.2.7 ~/.no-mistakes/worktrees/9eea230c3aae/01M46H7RECA78M61TK3ZQ7CRR9/packages/inputlayer-js

 ✓ tests/notifications.test.ts (6 tests) 6ms
 ✓ tests/result.test.ts (11 tests) 8ms
 ✓ tests/meta.test.ts (6 tests) 9ms
 ✓ tests/auth.test.ts (11 tests) 9ms
 ✓ tests/naming.test.ts (7 tests) 5ms
 ✓ tests/protocol.test.ts (18 tests) 13ms
 ✓ tests/relation.test.ts (16 tests) 10ms
 ✓ tests/proxy.test.ts (9 tests) 5ms
 ✓ tests/functions.test.ts (11 tests) 16ms
 ✓ tests/derived.test.ts (4 tests) 15ms
 ✓ tests/aggregations.test.ts (11 tests) 24ms
 ✓ tests/compiler.test.ts (40 tests) 25ms
 ✓ tests/params.test.ts (11 tests) 11ms
 ✓ tests/guards.test.ts (34 tests) 21ms
 ✓ tests/statement-errors.test.ts (30 tests) 148ms
 ✓ tests/subscription.test.ts (90 tests) 4090ms
   ✓ subscribe > a connection lost before the first snapshot reopens on the new connection  958ms
   ✓ subscribe > a connection lost during the session-rule check still yields the snapshot  744ms
   ✓ subscribe > a .subscribe reply lost past its deadline is retried after unsubscribing the id  1057ms
   ✓ subscribeGroup > a snapshot answering other queries is not taken as the group's  773ms
 ✓ tests/connection.test.ts (66 tests) 6898ms
   ✓ connection fixtures > overdue_stream_stalls  305ms
   ✓ connection fixtures > rate_limited_once  1003ms
   ✓ connection fixtures > rate_limited_twice  1003ms
   ✓ keepalive > a long call past the keepalive interval keeps a live connection  403ms
   ✓ cancellation > a queued call is sent with what is left of its deadline  303ms
   ✓ reconnect > a closed connection does not reopen  422ms

 Test Files  17 passed (17)
      Tests  381 passed (381)
   Start at  17:41:56
   Duration  8.30s (transform 2.34s, setup 0ms, collect 9.42s, tests 11.31s, environment 3ms, prepare 2.63s)

cd packages/inputlayer-js && npm run typecheck

> [email protected] typecheck
> tsc --noEmit


real	0m18.285s
user	0m15.274s
sys	0m4.586s

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ packages/inputlayer-js/src/connection.ts:696 - The fix works only because of timer ordering, and the ordering covers just the call that started the probe. probeServer() returns early while a probe is already pending (connection.ts:762), so a second in-flight call whose deadline passes inside the first call's probe window still races the drop. Concrete case: on a dead server (default maxInFlight 15), the client pipelines two writes with timeoutMs 50, sent 1 ms apart, with timeoutGraceMs 100. Write A expires at t=50: it arms overdue@150 and probe@150. Write B expires at t=51: it arms overdue@151, and its probe is skipped. At t=150, A fails OutcomeUnknown and then the probe fires. B and B's cancel are still outstanding (2 < 15), so the probe calls ws.terminate(). onClose → failInFlight (connection.ts:1148-1157) then rejects B with ConnectionLostError, even though B's deadline passed and it was waiting out its grace. That is exactly the misclassification issue JS SDK: dead_server connection fixture flakes (OutcomeUnknownError vs ConnectionLostError) #351 describes. The keepalive probe has the same problem (connection.ts:1239): if it is pending when a deadline expires, it fires before that call's overdue timer. Recommended durable fix at the shared boundary: in failInFlight, reject calls with call.graced set the same way overdue() does (OutcomeUnknownError for writes, DeadlineExceededError for reads), so the result no longer depends on which timer fires first. Optionally add a JS test with two staggered writes on a stalled server.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • ℹ️ packages/inputlayer-js/src/connection.ts:776 - Introduced in fix round 2 (a232240c). When the probe drops the connection, calls already in their grace period now fail with overdueError(call). That error's message says "No reply ${timeoutGraceMs} ms past the deadline", but these calls may have been waiting much less. Example: with grace 100, write A expires at 50 ms and write B at 60 ms. The probe drops the connection at 150 ms, so B fails only 90 ms past its deadline, yet its message still says 100 ms. The error class (OutcomeUnknownError or DeadlineExceededError) is correct, as the user asked; only the text is wrong. Fix: give overdueError an optional reason, so the probe path can say the connection was dropped because the server stopped answering instead of claiming the full grace elapsed. The only other caller is overdue() (connection.ts:717), where the text is accurate.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A write sent to a dead (SIGSTOPped) real server fails with OutcomeUnknownError at deadline+grace, never ConnectionLostError ✅ pass live live_fixed.txt S1: OutcomeUnknownError 15/15 (pre-fix live_base.txt: 1/15 ConnectionLostError)
Two pipelined writes with deadlines 10 ms apart on a dead server both fail OutcomeUnknownError (the second write's grace is cut short by the probe started by the first) ✅ pass live live_fixed.txt S2: both 15/15 OutcomeUnknownError; pre-fix live_base.txt: second write ConnectionLostError 15/15
Adversarial: when the probe drops the connection, a call not yet past its deadline still fails ConnectionLostError while the graced write fails OutcomeUnknownError ✅ pass live live_fixed.txt S3: graced write OutcomeUnknown 15/15, 10 s write ConnectionLost 15/15
A query past its deadline on a dead server fails DeadlineExceededError, not ConnectionLostError ✅ pass live live_fixed.txt S4: DeadlineExceededError 15/15 (pre-fix 1/15 ConnectionLost)
JS unit test connection fixtures > dead_server and the new deadline-probe tests are stable, and the new tests fail without the fix ⏸️ untested no The prior payload recorded only mock-server unit-test runs (30/30 loop passes, failures on base src) for this, not a live-product run, so it did not establish a live result; the live behavior is cover…
dead_server is in the shared conformance set and passes the Python SDK replay ⏸️ untested no The prior payload recorded only the mock-server pytest conformance replay (25 pass; dead_server 5/5), not a live-product run, so it did not establish a live result.
The JS SDK unit suite runs on PRs that touch the JS SDK or conformance fixtures ⏸️ untested no GitHub Actions cannot run inside this local test phase; only static parsing of ci.yml and a local npm test were done. The remote CI phase will exercise the new js-sdk job on this PR.
  • npx vitest run tests/connection.test.ts (66 pass)
  • npx vitest run tests/connection.test.ts -t &#34;dead_server|dead server&#34; run 30 times in a row while a cargo release build loaded the CPU: 30/30 pass
  • The same selector with src/connection.ts swapped to base 57ad2e95: both new tests fail with ConnectionLostError (3/3 runs). With the first fix commit fa5e9eb4 only, the two-pipelined-writes test fails.
  • npm test in packages/inputlayer-js (the JS unit suite the new CI job runs): 381 pass
  • uv run --extra dev pytest tests/test_connection_conformance.py -k dead_server 5 times plus the full conformance file (25 pass)
  • Parsed .github/workflows/ci.yml with PyYAML: the changes job outputs js, its filter covers packages/inputlayer-js/, packages/conformance/ and ci.yml, and the js-sdk job is gated on it and runs make pre-pr-js
  • cargo build --release --bin inputlayer-server, then run_live.sh: a throwaway server (temp data dir, port 18391); the SDK is driven through vite-node and the server is SIGSTOPped for each of 15 rounds × 4 scenarios, for both the fixed and the pre-fix connection.ts

✅ No issues found.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A write on a dead (SIGSTOPped) real server fails with OutcomeUnknownError at deadline+grace, not ConnectionLostError ✅ pass live live-sigstop-head.json: write A -> OutcomeUnknownError(outcome_unknown) @~500ms in 5/5 rounds
Adversarial: a second write whose deadline falls inside the first write's probe window also fails with OutcomeUnknownError when the probe drops the connection ✅ pass live live-sigstop-head.json: write B -> OutcomeUnknownError in 5/5 rounds; base SDK gives ConnectionLostError in 5/5 (live-sigstop-base.json)
A query past its deadline on a dead server fails with DeadlineExceededError when the probe drops the connection ✅ pass live live-sigstop-head.json: query R -> DeadlineExceededError in 5/5 rounds (base: ConnectionLostError)
Guard: a call not yet past its deadline still fails with ConnectionLostError when the probe drops the connection ✅ pass live live-sigstop-head.json: write L (60 s deadline) -> ConnectionLostError(closed) in 5/5 rounds
The flaky dead_server fixture and the new probe tests are deterministic on the fix and fail on the base SDK ⏸️ untested no The earlier payload recorded this only from the vitest unit/mock-server suite (25/25 on HEAD, 3 failures on base, in head-repeat-runs.txt and base-sdk-with-new-tests.txt). That is not a live product r…
dead_server is in the shared conformance set and the Python SDK passes it ⏸️ untested no The earlier payload recorded this only from the Python conformance harness (py-conformance.txt: dead_server PASSED). That harness drives the SDK against its own scripted mock server, not the real serv…
The JS SDK unit suite runs on PRs through the new CI js-sdk job ⏸️ untested no The workflow only runs on GitHub Actions for a pull request, so it cannot be triggered from this test phase; the PR/CI phase owns it. I ran the job's exact command (make pre-pr-js, passes locally) a…
  • npx vitest run tests/connection.test.ts -t &#34;dead_server|dead server|deadline probe&#34; repeated 25× on HEAD (25/25 pass)
  • Same targeted vitest run with src/connection.ts swapped to base ff6641af (dead_server fixture and both new probe tests fail with ConnectionLostError; restored afterward)
  • Live: built cargo build --release --bin inputlayer-server, ran it on a throwaway data dir/port, bundled the JS SDK (HEAD and base) with esbuild into the evidence dir, and drove node dead-server-live.cjs for 5 rounds each: warm-up write, then SIGSTOP the server with writes A (200 ms), B (210 ms), query R (205 ms) and write L (60 s) in flight, timeoutGraceMs 300
  • uv run --extra dev pytest tests/test_connection_conformance.py -v (dead_server conformance fixture passes, 25/25)
  • make pre-pr-js (the command the new CI js-sdk job runs: JS unit tests and tsc typecheck pass)
  • Parsed .github/workflows/ci.yml with PyYAML to confirm the js-sdk job is gated on needs.changes.outputs.js and runs make pre-pr-js
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ℹ️ docs/content/docs/guides/js-sdk.mdx:80 - The guide says calls in flight fail with ConnectionLostError when the connection drops. Since this change, when the liveness probe drops the connection, calls already past their deadline (in grace) fail as at the end of their grace instead (OutcomeUnknownError / DeadlineExceededError), which is consistent with line 78. Left unchanged as an internal edge case; consider adding a short qualifier if this should be user-visible.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

@jsam jsam changed the title fix(sdk-js): fail a dead server's write as outcome-unknown, not connection-lost (#351) fix(sdk-js): fail overdue writes as outcome-unknown when the probe drops a dead server (#351) Oct 5, 2026
jsam added 3 commits October 5, 2026 17:38
…ction-lost (#351)

At a sent call's deadline the connection armed the transport probe before
the call's grace timer. Both wait timeoutGraceMs, but each setTimeout reads
the clock anew, so the grace timer could land a millisecond later; the
unanswered probe then dropped the socket first and the close failed the
write as ConnectionLostError (1 in 5 runs of the dead_server fixture). Arm
the grace first so the call always fails overdue before the probe's drop.

A test forces the clock to move while the probe is sent, which fails every
time without the fix. dead_server joins the shared conformance set, with a
stall step, per-call timeout_ms and a deadline_grace_ms option in the Python
runner, and the JS SDK's unit tests and type check now run on PRs.

Closes #351
@jsam
jsam force-pushed the fm/fix-js-flake branch from e90095e to 2ab4151 Compare October 5, 2026 17:45
@jsam jsam changed the title fix(sdk-js): fail overdue writes as outcome-unknown when the probe drops a dead server (#351) fix(sdk-js): fail overdue writes as outcome-unknown when a dead server's connection is dropped Oct 5, 2026
@jsam
jsam marked this pull request as ready for review October 5, 2026 17:54
@jsam
jsam merged commit 75b315c into main Oct 5, 2026
24 checks passed
@jsam
jsam deleted the fm/fix-js-flake branch October 5, 2026 18:07
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