Coverage Report

Created: 2026-10-28 07:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/websocket_mock_server.cc
Line
Count
Source
1
/*
2
 * Copyright (C) Max Dymond, <cmeister2@gmail.com>, et al.
3
 *
4
 * SPDX-License-Identifier: curl
5
 */
6
7
/// @file
8
/// @brief Implementation of WebSocketMockServer.
9
10
#include "proto_fuzzer/websocket_mock_server.h"
11
12
#include <curl/websockets.h>
13
14
#include <algorithm>
15
#include <cstddef>
16
#include <cstdint>
17
#include <string>
18
#include <utility>
19
20
#include "proto_fuzzer/option_apply.h"
21
#include "proto_fuzzer/scenario_limits.h"
22
#include "proto_fuzzer/ws_accept_key.h"
23
#include "proto_fuzzer/ws_frame.h"
24
25
namespace proto_fuzzer {
26
27
namespace {
28
29
// Iteration caps for the post-handshake manual WS drive. Kept small because
30
// every iteration makes a curl_ws_recv / curl_ws_send call.
31
constexpr std::size_t kMaxWsRecvIterations = 128;
32
constexpr std::size_t kMaxWsSendIterations = 32;
33
34
/// Ordered list of flag combinations fed to curl_ws_send / curl_ws_start_frame
35
/// during the manual-drive tail. Sequencing matters: the TEXT|CONT entry puts
36
/// the encoder into `contfragment=true`, so subsequent CONT / TEXT|CONT /
37
/// BINARY|CONT entries then take the contfragment-aware branches in
38
/// ws_frame_flags2firstbyte that would otherwise be unreachable from the
39
/// fuzzer. A lone CURLWS_CONT runs first (contfragment=false → "No ongoing
40
/// fragmented message" failf), and a bare `0` directly after CURLWS_CONT
41
/// drives the "no flags given; interpreting as continuation" compatibility
42
/// path. (TEXT|BINARY), (CLOSE|CONT), (PING|CONT), (PONG|CONT) cover the
43
/// invalid-combination failf() branches; the trailing lone `0` reaches the
44
/// ordinary "no flags given" rejection with contfragment=false.
45
constexpr unsigned int kWsSendFlagMatrix[] = {
46
    CURLWS_CONT,
47
    CURLWS_TEXT,
48
    CURLWS_BINARY,
49
    CURLWS_TEXT | CURLWS_OFFSET,
50
    CURLWS_BINARY | CURLWS_OFFSET,
51
    CURLWS_TEXT | CURLWS_CONT,
52
    CURLWS_CONT,
53
    0,
54
    CURLWS_TEXT,
55
    CURLWS_BINARY | CURLWS_CONT,
56
    CURLWS_BINARY,
57
    CURLWS_PING,
58
    CURLWS_PONG,
59
    CURLWS_CLOSE,
60
    0,
61
    CURLWS_CLOSE | CURLWS_CONT,
62
    CURLWS_PING | CURLWS_CONT,
63
    CURLWS_PONG | CURLWS_CONT,
64
    CURLWS_TEXT | CURLWS_BINARY,
65
};
66
67
/// Find "Sec-WebSocket-Key:" in the request and return the trimmed value.
68
/// @param request The raw HTTP request bytes buffered from the client.
69
/// @return The header value, or an empty string if not found.
70
14.8k
std::string ExtractWebSocketKey(const std::string& request) {
71
14.8k
  static const char kHeader[] = "Sec-WebSocket-Key:";
72
14.8k
  std::size_t pos = request.find(kHeader);
73
14.8k
  if (pos == std::string::npos) {
74
9
    return {};
75
9
  }
76
14.8k
  pos += sizeof(kHeader) - 1;
77
29.9k
  while (pos < request.size() && (request[pos] == ' ' || request[pos] == '\t')) {
78
15.0k
    ++pos;
79
15.0k
  }
80
14.8k
  std::size_t end = request.find("\r\n", pos);
81
14.8k
  if (end == std::string::npos) {
82
2
    return {};
83
2
  }
84
15.5k
  while (end > pos && (request[end - 1] == ' ' || request[end - 1] == '\t')) {
85
738
    --end;
86
738
  }
87
14.8k
  return request.substr(pos, end - pos);
88
14.8k
}
89
90
/// SHA1(key + WS magic guid), base64-encoded — RFC 6455 §4.2.2.
91
/// Delegates to the standalone implementation in ws_accept_key.h so we don't
92
/// need OpenSSL.
93
14.8k
std::string ComputeWebSocketAccept(const std::string& key) { return proto_fuzzer::ComputeWebSocketAcceptKey(key); }
94
95
/// Build the ordered list of chunks to deliver once the 101 handshake has
96
/// completed. Mixes raw `on_readable` bytes (fuzzer-controlled) with serialised
97
/// `server_frames` (structured RFC 6455 frames from the proto) under a shared
98
/// shared response-chunk budget.
99
19.9k
std::vector<std::string> BuildFrameChunks(const curl::fuzzer::proto::Connection& conn) {
100
19.9k
  std::vector<std::string> chunks;
101
19.9k
  chunks.reserve(scenario_limits::kMaxResponseChunks);
102
19.9k
  const std::size_t raw_budget = std::min<std::size_t>(scenario_limits::kMaxResponseChunks, conn.on_readable_size());
103
29.3k
  for (std::size_t i = 0; i < raw_budget; ++i) {
104
9.33k
    chunks.emplace_back(conn.on_readable(i));
105
9.33k
  }
106
19.9k
  const std::size_t frame_budget = scenario_limits::kMaxResponseChunks - chunks.size();
107
19.9k
  const std::size_t frame_count = std::min<std::size_t>(frame_budget, conn.server_frames_size());
108
33.8k
  for (std::size_t i = 0; i < frame_count; ++i) {
109
13.8k
    chunks.emplace_back(SerializeWebSocketFrame(conn.server_frames(static_cast<int>(i))));
110
13.8k
  }
111
19.9k
  return chunks;
112
19.9k
}
113
114
/// WRITEFUNCTION / HEADERFUNCTION installed on the easy handle by
115
/// WebSocketMockServer::Install. Pokes curl_ws_meta on every invocation (so
116
/// the Curl_is_in_callback-guarded branch stays covered), and fires a
117
/// one-shot curl_ws_send probe to reach ws_send_raw_blocking — that target
118
/// is unreachable unless CURLWS_RAW_MODE is set AND the caller is inside a
119
/// callback. The probe is sized larger than typical backpressure recv
120
/// buffers so the partial-write / SOCKET_WRITABLE loop engages under a
121
/// tightened SO_RCVBUF. The one-shot gate keeps the per-scenario cost
122
/// bounded when SOCKET_WRITABLE times out. WRITEDATA is the owning
123
/// WebSocketMockServer so callback state lives on the server, not globals.
124
108k
size_t WebSocketWriteCallback(void* /*contents*/, size_t size, size_t nmemb, void* userdata) {
125
108k
  auto* server = static_cast<WebSocketMockServer*>(userdata);
126
108k
  if (server != nullptr) {
127
34.9k
    CURL* easy = server->easy_handle();
128
34.9k
    if (easy != nullptr) {
129
34.9k
      (void)curl_ws_meta(easy);
130
34.9k
      if (!server->ws_probe_fired()) {
131
2.06k
        server->MarkWsProbeFired();
132
2.06k
        static unsigned char kProbe[16384];
133
2.06k
        std::fill(kProbe, kProbe + sizeof(kProbe), 'P');
134
2.06k
        std::size_t sent = 0;
135
2.06k
        (void)curl_ws_send(easy, kProbe, sizeof(kProbe), &sent, 0, 0);
136
2.06k
      }
137
34.9k
    }
138
34.9k
  }
139
108k
  return size * nmemb;
140
108k
}
141
142
/// Drain curl_ws_recv in a tight loop until it returns CURLE_AGAIN / nothing
143
/// pending. Bounded; not expected to do anything on well-formed scenarios.
144
8.44k
std::size_t DrainWsRecv(CURL* easy) {
145
8.44k
  std::size_t total_received = 0;
146
71.0k
  for (std::size_t i = 0; i < kMaxWsRecvIterations; ++i) {
147
70.9k
    unsigned char buffer[4096];
148
70.9k
    std::size_t nread = 0;
149
70.9k
    const struct curl_ws_frame* meta = nullptr;
150
70.9k
    CURLcode rr = curl_ws_recv(easy, buffer, sizeof(buffer), &nread, &meta);
151
70.9k
    if (rr == CURLE_AGAIN) {
152
3.73k
      break;
153
3.73k
    }
154
67.1k
    if (rr != CURLE_OK && rr != CURLE_GOT_NOTHING) {
155
4.58k
      break;
156
4.58k
    }
157
62.6k
    if (nread == 0 && meta == nullptr) {
158
0
      break;
159
0
    }
160
62.6k
    total_received += nread;
161
62.6k
  }
162
8.44k
  return total_received;
163
8.44k
}
164
165
}  // namespace
166
167
/// Mirror the sequential CURLOPT_CONNECT_ONLY setopts applied by
168
/// ApplyScenarioOptions. Values above 2 are rejected by libcurl and therefore
169
/// cannot replace the last accepted setting. Use the same descriptor-aware
170
/// scalar decoder as ApplySetOption so compatibility inputs and cross-family
171
/// mutations cannot make curl and its mock disagree about manual delivery.
172
/// Restricting the scan to RuntimeOptionCount also prevents a compatibility-
173
/// only suffix from changing mock delivery after curl stops observing options.
174
/// @param scenario Structured input whose retained option prefix is inspected.
175
/// @return True when the final accepted CONNECT_ONLY value is 2.
176
19.9k
bool ScenarioRequestsManualWsDrive(const curl::fuzzer::proto::Scenario& scenario) {
177
19.9k
  bool manual_delivery = false;
178
19.9k
  const std::size_t option_count = RuntimeOptionCount(scenario);
179
115k
  for (std::size_t index = 0; index < option_count; ++index) {
180
95.6k
    const auto& option = scenario.options(static_cast<int>(index));
181
95.6k
    if (option.option_id() != curl::fuzzer::proto::CURLOPT_CONNECT_ONLY) {
182
92.9k
      continue;
183
92.9k
    }
184
2.70k
    const std::uint64_t value = DecodeIntegralOptionValue(option);
185
2.70k
    if (value <= 2) {
186
2.01k
      manual_delivery = value == 2;
187
2.01k
    }
188
2.70k
  }
189
19.9k
  return manual_delivery;
190
19.9k
}
191
192
/// Construct an idle WebSocketMockServer with no queued frames. Install()
193
/// on the base class and DriveScenario() configure it from a Scenario proto.
194
WebSocketMockServer::WebSocketMockServer()
195
20.3k
    : next_chunk_(0),
196
20.3k
      manual_delivery_(false),
197
20.3k
      handshake_sent_(false),
198
20.3k
      ws_probe_fired_(false),
199
20.3k
      easy_handle_(nullptr),
200
20.3k
      manual_received_bytes_(0) {}
201
202
/// Default destructor; the owned MockConnection (if any) cleans up its socketpair.
203
20.3k
WebSocketMockServer::~WebSocketMockServer() = default;
204
205
/// Install the shared socket trampolines via the base, then layer a
206
/// WebSocket-aware WRITEFUNCTION / HEADERFUNCTION on top. These call curl_ws_meta
207
/// inside the callback to reach its Curl_is_in_callback-guarded branch. WRITEDATA
208
/// points to this server so the callback can consult the easy handle and the
209
/// one-shot probe flag.
210
/// @param easy The curl easy handle to configure.
211
20.2k
void WebSocketMockServer::Install(CURL* easy) {
212
20.2k
  MockServerBase::Install(easy);
213
20.2k
  easy_handle_ = easy;
214
20.2k
  ws_probe_fired_ = false;
215
20.2k
  curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, &WebSocketWriteCallback);
216
20.2k
  curl_easy_setopt(easy, CURLOPT_WRITEDATA, this);
217
20.2k
  curl_easy_setopt(easy, CURLOPT_HEADERFUNCTION, &WebSocketWriteCallback);
218
20.2k
}
219
220
/// Return the one-shot probe gate. The write callback calls curl_ws_send once
221
/// per scenario to reach ws_send_raw_blocking; firing it per callback would wedge
222
/// each frame under backpressure. Public so the free-function callback can read
223
/// and flip it; reset by Install().
224
/// @return true once the one-shot WS probe has fired for this scenario.
225
34.9k
bool WebSocketMockServer::ws_probe_fired() const { return ws_probe_fired_; }
226
227
/// Flip the one-shot gate closed. Called from the write callback before it
228
/// invokes curl_ws_send, so subsequent callback entries skip the probe.
229
2.06k
void WebSocketMockServer::MarkWsProbeFired() { ws_probe_fired_ = true; }
230
231
/// @return the curl easy handle cached by Install() for the write callback.
232
34.9k
CURL* WebSocketMockServer::easy_handle() const { return easy_handle_; }
233
234
/// @return Application payload bytes returned by curl_ws_recv during the
235
/// latest manual-drive tail.
236
20.2k
std::size_t WebSocketMockServer::manual_received_bytes() const { return manual_received_bytes_; }
237
238
/// Queue RFC 6455 wire-byte chunks to emit once the handshake has completed.
239
/// Resets the next-chunk cursor.
240
/// @param frames Ordered list of chunk byte strings.
241
19.9k
void WebSocketMockServer::SetFrames(std::vector<std::string> frames) {
242
19.9k
  frames_ = std::move(frames);
243
19.9k
  next_chunk_ = 0;
244
19.9k
}
245
246
/// Toggle streaming (false, default) vs manual (true) chunk delivery.
247
/// @param manual Whether to suppress automatic chunk pushing by the
248
///        drive loop.
249
19.9k
void WebSocketMockServer::SetManualDelivery(bool manual) { manual_delivery_ = manual; }
250
251
/// @return true if chunks are caller-driven rather than drive-loop-driven.
252
314k
bool WebSocketMockServer::manual_delivery() const { return manual_delivery_; }
253
254
/// @return true once a 101 Switching Protocols response has been written.
255
838k
bool WebSocketMockServer::handshake_sent() const { return handshake_sent_; }
256
257
/// @return true if at least one frame chunk has not yet been sent.
258
263k
bool WebSocketMockServer::has_more_chunks() const { return next_chunk_ < frames_.size(); }
259
260
/// @return the number of queued chunks not yet consumed.
261
8.44k
std::size_t WebSocketMockServer::remaining_chunks() const {
262
8.44k
  return next_chunk_ >= frames_.size() ? 0 : frames_.size() - next_chunk_;
263
8.44k
}
264
265
/// Access a pending chunk without consuming it.
266
/// @param index Offset from the next-pending cursor.
267
/// @return reference to the chunk byte string.
268
8.36k
const std::string& WebSocketMockServer::PeekChunk(std::size_t index) const { return frames_[next_chunk_ + index]; }
269
270
/// Advance the pending-chunk cursor by one. No-op when no chunks remain.
271
8.36k
void WebSocketMockServer::ConsumeChunk() {
272
8.36k
  if (next_chunk_ < frames_.size()) {
273
8.36k
    ++next_chunk_;
274
8.36k
  }
275
8.36k
}
276
277
/// Construct the transport used for the WebSocket exchange. Secure WebSocket
278
/// peers override this factory while retaining the same Upgrade and frame
279
/// state machine.
280
/// @return a new plaintext socketpair connection.
281
9.79k
std::unique_ptr<MockConnection> WebSocketMockServer::CreateConnection() { return std::make_unique<MockConnection>(); }
282
283
/// Consume the plaintext Upgrade request without applying DrainIncoming's
284
/// backpressure limit. This preserves the historical WS and compatibility
285
/// behavior; layered transports override the hook to perform their decoding.
286
/// @return Number of request bytes appended during this call.
287
15.8k
std::size_t WebSocketMockServer::DrainHandshakeData() {
288
15.8k
  if (!connection_) {
289
0
    return 0;
290
0
  }
291
15.8k
  const std::size_t size_before = ws_request_buffer_.size();
292
15.8k
  connection_->ReadAvailable(&ws_request_buffer_);
293
15.8k
  return ws_request_buffer_.size() - size_before;
294
15.8k
}
295
296
/// Flush application writes when the carrier queues them. Plain socket writes
297
/// are synchronous, so the historical transport has nothing to do here.
298
19.4k
void WebSocketMockServer::FlushTransport() {}
299
300
/// Collect application bytes consumed by the active transport. Plaintext
301
/// connections report socket bytes directly; TLS connections report them only
302
/// after record decryption, keeping the WebSocket handshake transport-agnostic.
303
/// @param data First byte of one application-data fragment.
304
/// @param size Number of bytes available at `data`.
305
53.4k
void WebSocketMockServer::ObserveIncomingData(const unsigned char* data, std::size_t size) {
306
53.4k
  if (!handshake_sent_ && data != nullptr && size != 0) {
307
6.62k
    ws_request_buffer_.append(reinterpret_cast<const char*>(data), size);
308
6.62k
  }
309
53.4k
}
310
311
/// Called by the OPENSOCKETFUNCTION trampoline in the base class. Creates the
312
/// MockConnection but does NOT write anything — the handshake is driven later
313
/// by TryAdvanceHandshake().
314
/// @param purpose Socket role requested by curl; this peer accepts one outbound
315
///                WebSocket transport only.
316
/// @param address Intended destination retained by curl; the already-connected
317
///                socketpair leaves it unchanged.
318
/// @return the client-side fd to hand to libcurl, or CURL_SOCKET_BAD on
319
///         failure.
320
16.5k
curl_socket_t WebSocketMockServer::HandleOpenSocket(curlsocktype purpose, struct curl_sockaddr* address) {
321
16.5k
  (void)purpose;
322
16.5k
  (void)address;
323
16.5k
  if (connection_) {
324
0
    return CURL_SOCKET_BAD;
325
0
  }
326
16.5k
  connection_ = CreateConnection();
327
16.5k
  if (!connection_ || !connection_->ok()) {
328
0
    connection_.reset();
329
0
    return CURL_SOCKET_BAD;
330
0
  }
331
16.5k
  connection_->SetIncomingDataObserver(this);
332
16.5k
  ApplyPendingBackpressure();
333
  // Wait for curl's Upgrade request before we write anything — the drive
334
  // loop calls TryAdvanceHandshake() to drive that exchange.
335
16.5k
  return connection_->take_client_fd();
336
16.5k
}
337
338
/// Push raw bytes onto the server fd. Used by the manual-drive path to feed
339
/// frame bytes directly into curl without any mock-side framing.
340
/// @param data Buffer to send.
341
/// @param size Number of bytes in 'data'.
342
/// @return false on short or failed write.
343
6.76k
bool WebSocketMockServer::PushRawBytes(const unsigned char* data, std::size_t size) {
344
6.76k
  if (!connection_) {
345
0
    return false;
346
0
  }
347
6.76k
  connection_->DrainIncoming();
348
6.76k
  const bool written = connection_->WriteAll(data, size);
349
6.76k
  if (written) {
350
6.52k
    FlushTransport();
351
6.52k
  }
352
6.76k
  return written;
353
6.76k
}
354
355
/// Push the next queued frame when curl is ready. Used in streaming mode;
356
/// the drive loop calls this after the handshake has been sent. Shuts
357
/// the write side once the last chunk is delivered.
358
/// @return true when a chunk was consumed from the script.
359
10.6k
bool WebSocketMockServer::DeliverNextChunk() {
360
10.6k
  if (!connection_ || next_chunk_ >= frames_.size()) {
361
0
    return false;
362
0
  }
363
10.6k
  connection_->DrainIncoming();
364
10.6k
  const std::string& chunk = frames_[next_chunk_++];
365
10.6k
  if (!chunk.empty()) {
366
10.0k
    if (connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size())) {
367
10.0k
      FlushTransport();
368
10.0k
    }
369
10.0k
  }
370
10.6k
  if (next_chunk_ >= frames_.size()) {
371
3.08k
    connection_->ShutdownWrite();
372
3.08k
  }
373
10.6k
  return true;
374
10.6k
}
375
376
/// Drive the WebSocket opening handshake: read whatever curl has written so
377
/// far, and once we've seen the end of the request headers, reply with a
378
/// valid 101 Switching Protocols.
379
/// @param made_progress Optional result set when transport or handshake state
380
///        advanced during this call.
381
/// @return true once the 101 response has been written (idempotent afterwards).
382
35.8k
bool WebSocketMockServer::TryAdvanceHandshake(bool* made_progress) {
383
35.8k
  if (made_progress != nullptr) {
384
35.8k
    *made_progress = false;
385
35.8k
  }
386
35.8k
  if (handshake_sent_ || !connection_) {
387
6.99k
    return handshake_sent_;
388
6.99k
  }
389
28.8k
  if (DrainHandshakeData() != 0 && made_progress != nullptr) {
390
22.1k
    *made_progress = true;
391
22.1k
  }
392
28.8k
  if (ws_request_buffer_.find("\r\n\r\n") == std::string::npos) {
393
14.0k
    return false;
394
14.0k
  }
395
14.8k
  std::string key = ExtractWebSocketKey(ws_request_buffer_);
396
  // Even if parsing failed, reply with *something* so curl doesn't wedge.
397
  // A bad Accept exercises curl's handshake-error path.
398
14.8k
  std::string accept = key.empty() ? std::string("AAAAAAAAAAAAAAAAAAAAAAAAAAA=") : ComputeWebSocketAccept(key);
399
14.8k
  std::string response =
400
14.8k
      "HTTP/1.1 101 Switching Protocols\r\n"
401
14.8k
      "Upgrade: websocket\r\n"
402
14.8k
      "Connection: Upgrade\r\n"
403
14.8k
      "Sec-WebSocket-Accept: " +
404
14.8k
      accept + "\r\n\r\n";
405
  // CONNECT_ONLY may leave already-read bytes after the 101 headers. Put one
406
  // bounded frame in the same peer write so curl has to preserve those bytes
407
  // for the later curl_ws_recv call instead of discarding them when the
408
  // opening transfer finishes. Remaining chunks still exercise the explicit
409
  // manual-delivery tail below.
410
14.8k
  if (manual_delivery() && has_more_chunks()) {
411
1.23k
    response.append(PeekChunk(0));
412
1.23k
    ConsumeChunk();
413
1.23k
  }
414
14.8k
  connection_->WriteAll(reinterpret_cast<const unsigned char*>(response.data()), response.size());
415
14.8k
  FlushTransport();
416
14.8k
  handshake_sent_ = true;
417
14.8k
  if (made_progress != nullptr) {
418
14.8k
    *made_progress = true;
419
14.8k
  }
420
14.8k
  return true;
421
28.8k
}
422
423
/// Seed the mock from the scenario, then run the perform loop. Drives the 101
424
/// handshake on every iteration; in streaming mode also pushes queued frame
425
/// chunks as curl becomes readable. In manual mode (CURLOPT_CONNECT_ONLY=2)
426
/// chunks are left alone during the loop — once the loop returns, this method
427
/// pushes them as raw bytes and exercises curl_ws_recv / curl_ws_send against
428
/// a small flag matrix.
429
/// @param multi    caller-owned multi; 'easy' is already added.
430
/// @param easy     the curl easy handle attached to this mock.
431
/// @param scenario source of the frame chunks and CONNECT_ONLY setting.
432
19.9k
void WebSocketMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
433
19.9k
  SetManualDelivery(ScenarioRequestsManualWsDrive(scenario));
434
19.9k
  manual_received_bytes_ = 0;
435
  // initial_response is unused in WS mode; we synthesise the 101 dynamically
436
  // from curl's Upgrade request.
437
19.9k
  SetFrames(BuildFrameChunks(scenario.connection()));
438
439
19.9k
  int still_running = 1;
440
19.9k
  int idle_iterations = 0;
441
19.9k
  int drive_iterations = 0;
442
19.9k
  bool pollset_probed = false;
443
19.9k
  const bool timed_drive = UsesTimedDrive(scenario);
444
19.9k
  const int idle_limit = timed_drive ? kMaxTimedIdleIterations : kMaxIdleIterations;
445
19.9k
  CURLMcode rc = CURLM_OK;
446
447
299k
  while (still_running && idle_iterations < idle_limit && drive_iterations++ < kMaxDriveIterations) {
448
290k
    bool made_progress = false;
449
290k
    const int running_before = still_running;
450
290k
    rc = curl_multi_perform(multi, &still_running);
451
290k
    if (rc != CURLM_OK) {
452
0
      break;
453
0
    }
454
290k
    made_progress = still_running != running_before;
455
290k
    if (timed_drive && still_running && !pollset_probed) {
456
      // At this point curl has created the Upgrade connection but the mock has
457
      // not necessarily replied, which gives curl_multi_poll a meaningful WS
458
      // filter state without adding a timeout to fixed fast-lane inputs.
459
1.97k
      ProbeMultiPollset(multi);
460
1.97k
      pollset_probed = true;
461
1.97k
    }
462
    // Drive the 101 handshake on every iteration; no-op once sent.
463
290k
    if (!handshake_sent()) {
464
35.8k
      bool handshake_progress = false;
465
35.8k
      (void)TryAdvanceHandshake(&handshake_progress);
466
35.8k
      made_progress = handshake_progress || made_progress;
467
35.8k
    }
468
290k
    if (!still_running) {
469
10.8k
      break;
470
10.8k
    }
471
472
    // Only push frame chunks in streaming mode — in manual mode the caller
473
    // will push them below after the handshake.
474
279k
    if (!manual_delivery() && handshake_sent() && has_more_chunks()) {
475
10.6k
      made_progress = DeliverNextChunk() || made_progress;
476
269k
    } else if (handshake_sent() && connection() != nullptr) {
477
      // Uploading WS scenarios can have no server frames at all. Drain their
478
      // encoded client data here so they make deterministic progress and do
479
      // not have to wait for CURLOPT_TIMEOUT_MS just to exercise cr_ws_read.
480
252k
      made_progress = connection()->DrainIncoming() != 0 || made_progress;
481
252k
    }
482
483
279k
    if (made_progress) {
484
40.6k
      idle_iterations = 0;
485
40.6k
      continue;
486
40.6k
    }
487
488
239k
    if (timed_drive) {
489
165k
      (void)WaitOnMultiFdset(multi, &rc);
490
165k
      if (rc != CURLM_OK) {
491
0
        break;
492
0
      }
493
165k
    }
494
239k
    ++idle_iterations;
495
239k
  }
496
497
19.9k
  if (!manual_delivery() || !handshake_sent()) {
498
18.6k
    return;
499
18.6k
  }
500
501
  // Manual-drive tail: feed every remaining scripted chunk straight onto the
502
  // server fd as raw frame bytes, draining curl_ws_recv between each push.
503
8.44k
  while (remaining_chunks() > 0) {
504
7.12k
    const std::string& chunk = PeekChunk(0);
505
7.12k
    if (!chunk.empty()) {
506
6.76k
      PushRawBytes(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
507
6.76k
    }
508
7.12k
    ConsumeChunk();
509
7.12k
    manual_received_bytes_ += DrainWsRecv(easy);
510
7.12k
  }
511
  // Final drain in case frame parsing produced more work after the last push.
512
1.31k
  manual_received_bytes_ += DrainWsRecv(easy);
513
514
1.31k
  const auto& probes = scenario.connection().manual_probes();
515
1.31k
  static const unsigned char kPayload[] = "hello-from-proto-fuzzer";
516
1.31k
  const std::size_t payload_len = sizeof(kPayload) - 1;
517
518
1.31k
  if (probes.flag_matrix()) {
519
    // Exercise curl_ws_send with a fixed matrix of flags. We don't care whether
520
    // the send actually lands on the wire — the point is to reach the encode
521
    // paths in ws_enc_add_frame / ws_enc_write_head.
522
442
    std::size_t iteration = 0;
523
8.39k
    for (unsigned int flags : kWsSendFlagMatrix) {
524
8.39k
      if (iteration++ >= kMaxWsSendIterations) {
525
0
        break;
526
0
      }
527
      // Announce the frame via the public curl_ws_start_frame entrypoint before
528
      // the actual send. In non-raw mode this writes the frame head into the
529
      // sendbuf; the follow-up curl_ws_send with the same flags finishes the
530
      // exchange or fails cleanly on invalid flag combos.
531
8.39k
      (void)curl_ws_start_frame(easy, flags, static_cast<curl_off_t>(payload_len));
532
8.39k
      std::size_t sent = 0;
533
8.39k
      curl_off_t fragsize = (flags & CURLWS_OFFSET) ? static_cast<curl_off_t>(payload_len) : 0;
534
8.39k
      (void)curl_ws_send(easy, kPayload, payload_len, &sent, fragsize, flags);
535
8.39k
      if (connection() != nullptr) {
536
8.39k
        connection()->DrainIncoming();
537
8.39k
      }
538
8.39k
    }
539
442
  }
540
541
1.31k
  if (probes.unaligned_send()) {
542
    // Multi-call mis-sized send probe: declare a fragsize=200 frame, send 23
543
    // bytes, then call curl_ws_send again with a buflen much bigger than the
544
    // remaining payload. Hits the "unaligned frame size" failf in ws_enc_send.
545
401
    constexpr curl_off_t kBigFrag = 200;
546
401
    (void)curl_ws_start_frame(easy, CURLWS_TEXT | CURLWS_OFFSET, kBigFrag);
547
401
    std::size_t sent = 0;
548
401
    (void)curl_ws_send(easy, kPayload, payload_len, &sent, kBigFrag, CURLWS_TEXT | CURLWS_OFFSET);
549
    // Now enc.payload_remain ≈ kBigFrag - payload_len. A follow-up send with
550
    // buflen > remaining trips the guard at ws_enc_send:~1050.
551
401
    std::size_t sent2 = 0;
552
401
    constexpr std::size_t kOverrun = 500;
553
401
    unsigned char overrun[kOverrun];
554
401
    std::fill(overrun, overrun + kOverrun, 'X');
555
401
    (void)curl_ws_send(easy, overrun, kOverrun, &sent2, kBigFrag, CURLWS_TEXT | CURLWS_OFFSET);
556
401
    if (connection() != nullptr) {
557
401
      connection()->DrainIncoming();
558
401
    }
559
401
  }
560
561
1.31k
  if (probes.raw_send()) {
562
    // Raw-mode send path: reachable only when CURLOPT_WS_OPTIONS has
563
    // CURLWS_RAW_MODE set. curl_ws_send with flags=0, fragsize=0 takes the
564
    // data->set.ws_raw_mode branch → ws_send_raw. No-op for non-raw scenarios
565
    // (hits the "no flags given" failure path instead).
566
331
    std::size_t sent = 0;
567
331
    (void)curl_ws_send(easy, kPayload, payload_len, &sent, 0, 0);
568
331
    if (connection() != nullptr) {
569
331
      connection()->DrainIncoming();
570
331
    }
571
331
  }
572
1.31k
}
573
574
}  // namespace proto_fuzzer