NOT-OMEGA commited on
Commit
2bfb588
·
verified ·
1 Parent(s): ad3c7e2

Delete BUG_REPORT.md

Browse files
Files changed (1) hide show
  1. BUG_REPORT.md +0 -511
BUG_REPORT.md DELETED
@@ -1,511 +0,0 @@
1
- # Complete Bug Report — HFT Exchange Simulator
2
-
3
- **Total Bugs Found: 17**
4
- **Files Modified: 8**
5
- **Severity: 4 Critical, 9 Medium, 4 Low**
6
-
7
- ---
8
-
9
- ## BUG #1 — Windows Pipe Crash (CRITICAL)
10
-
11
- **File:** `app.py` (line ~72)
12
- **What was the bug:**
13
- The old code used `select.select()` to read the C++ engine's stdout with a timeout. On Linux, `select.select()` works with pipe file descriptors. But on Windows, `select.select()` ONLY works with sockets — NOT with pipes. When the Python server tried to read the engine response, Windows threw an `OSError` and the entire server crashed immediately on every single API call.
14
-
15
- **What the old code looked like:**
16
- ```python
17
- ready, _, _ = select.select([self.proc.stdout], [], [], ENGINE_TIMEOUT)
18
- if ready:
19
- response = self.proc.stdout.readline().strip()
20
- ```
21
-
22
- **How I fixed it:**
23
- Replaced `select.select()` with a cross-platform thread-based approach. I spawn a short-lived daemon thread that calls `readline()`. The main thread waits for that thread with a timeout using `thread.join(timeout=ENGINE_TIMEOUT)`. If the thread is still alive after the timeout, we kill the engine and restart it.
24
-
25
- **What the fixed code looks like:**
26
- ```python
27
- response_container = []
28
- def _read():
29
- resp = self.proc.stdout.readline()
30
- response_container.append(resp)
31
-
32
- reader = threading.Thread(target=_read, daemon=True)
33
- reader.start()
34
- reader.join(timeout=ENGINE_TIMEOUT)
35
-
36
- if reader.is_alive():
37
- self.proc.kill()
38
- self.proc = None
39
- self._start()
40
- raise HTTPException(504, "Engine timeout — restarted")
41
- ```
42
-
43
- **Why this matters:** Without this fix, the entire application was 100% non-functional on Windows. Every API request would crash.
44
-
45
- ---
46
-
47
- ## BUG #2 — Server Crashes If Binary Is Missing (CRITICAL)
48
-
49
- **File:** `app.py` (line ~34)
50
- **What was the bug:**
51
- The `EngineProcess` was created at module-level (`engine = EngineProcess()`). Inside `__init__`, it called `_start()` which tried to open the C++ binary via `subprocess.Popen()`. If the binary file didn't exist (because you haven't built the C++ code yet), `Popen` would throw an exception and the entire FastAPI server would fail to start — you couldn't even see an error page.
52
-
53
- **How I fixed it:**
54
- 1. In `_start()`, added a check: if the binary file doesn't exist, set `self.proc = None` and return gracefully instead of crashing.
55
- 2. Changed the module-level `engine = EngineProcess()` to `engine = None` (lazy initialization). The engine is only created when the first API request comes in via `_get_engine()`.
56
-
57
- **What the fixed code looks like:**
58
- ```python
59
- engine: Optional[EngineProcess] = None
60
-
61
- def _get_engine() -> EngineProcess:
62
- global engine
63
- if engine is None:
64
- engine = EngineProcess()
65
- return engine
66
- ```
67
-
68
- **Why this matters:** Developers can now start the Python server, see the API docs, and get a clear "binary not found" error message instead of an opaque crash.
69
-
70
- ---
71
-
72
- ## BUG #3 — Missing .exe Suffix on Windows (CRITICAL)
73
-
74
- **File:** `app.py` (line ~21)
75
- **What was the bug:**
76
- The binary path was hardcoded as `"hft_engine"`. On Windows, compiled C++ executables have a `.exe` extension. So the server was looking for a file called `hft_engine` which doesn't exist on Windows — it should be looking for `hft_engine.exe`.
77
-
78
- **What the old code looked like:**
79
- ```python
80
- ENGINE_PATH = Path(__file__).parent / "build" / "hft_engine"
81
- ```
82
-
83
- **How I fixed it:**
84
- ```python
85
- _engine_name = "hft_engine.exe" if sys.platform == "win32" else "hft_engine"
86
- ENGINE_PATH = Path(__file__).parent / "build" / _engine_name
87
- ```
88
-
89
- **Why this matters:** Without this, even if the binary was built successfully on Windows, the server would never find it.
90
-
91
- ---
92
-
93
- ## BUG #4 — Sell-Side Iterator Invalidation (CRITICAL)
94
-
95
- **File:** `src/core/order_book.cpp` (line ~113)
96
- **What was the bug:**
97
- This is the most dangerous C++ bug in the entire project. When a SELL order comes in, the matching engine needs to match it against the best BID (highest price). The old code used a `for` loop iterating backwards through `bids_` (a `std::map`):
98
-
99
- ```cpp
100
- for (auto it = bids_.end(); it != bids_.begin() && incoming.remaining() > 0; ) {
101
- --it;
102
- // ... matching logic ...
103
- if (resting->is_done()) {
104
- level.orders.pop_front();
105
- orders_.erase(resting->id);
106
- if (level.empty()) {
107
- it = bids_.erase(it); // ← erases the current element
108
- break; // ← breaks inner while loop
109
- }
110
- }
111
- }
112
- // Back to outer for loop → does --it on the ERASED iterator → UNDEFINED BEHAVIOR
113
- ```
114
-
115
- The problem: After `bids_.erase(it)`, the iterator `it` is set to the next element. Then `break` exits the inner `while` loop. Then the outer `for` loop does `--it` on this already-advanced iterator. But worse — if we erased the first element, `--it` could go before `begin()`, which is undefined behavior. This could cause random crashes, corrupted order books, or silent wrong fills under heavy load.
116
-
117
- **How I fixed it:**
118
- Rewrote the entire sell-side loop to use a `while` loop that re-fetches `std::prev(bids_.end())` on each iteration. This way, after erasing a level, we simply go back to the top of the `while` loop and grab the new best bid. No iterator can be invalidated.
119
-
120
- ```cpp
121
- while (!bids_.empty() && incoming.remaining() > 0) {
122
- auto it = std::prev(bids_.end()); // always get fresh best bid
123
- auto& [px, level] = *it;
124
-
125
- if (incoming.type == OrderType::LIMIT || incoming.type == OrderType::IOC) {
126
- if (px < incoming.price) break;
127
- }
128
-
129
- while (!level.orders.empty() && incoming.remaining() > 0) {
130
- // ... matching logic (unchanged) ...
131
- }
132
-
133
- if (level.empty()) {
134
- bids_.erase(it); // safe — we won't use 'it' again
135
- } else {
136
- break;
137
- }
138
- }
139
- ```
140
-
141
- **Why this matters:** This is the kind of bug that works fine in testing with small order books but causes random crashes in production under heavy load. A quant firm would lose real money.
142
-
143
- ---
144
-
145
- ## BUG #5 — to_price() Rounds Wrong for Negative Numbers
146
-
147
- **File:** `src/core/types.hpp` (line ~17)
148
- **What was the bug:**
149
- The `to_price()` function converts a `double` (like 185.5050) to an `int64_t` (like 1855050) by multiplying by 10000 and adding 0.5:
150
-
151
- ```cpp
152
- inline Price to_price(double d) { return static_cast<Price>(d * PRICE_SCALE + 0.5); }
153
- ```
154
-
155
- This `+0.5` trick works for positive numbers (it rounds 1.6 → 2, 1.4 → 1). But for negative numbers, it's WRONG:
156
- - `-1.5 * 10000 + 0.5 = -14999.5` → truncated to `-14999`
157
- - Correct answer should be `-15000`
158
-
159
- This matters because PnL calculations can produce negative intermediate values.
160
-
161
- **How I fixed it:**
162
- ```cpp
163
- inline Price to_price(double d) { return static_cast<Price>(std::llround(d * PRICE_SCALE)); }
164
- ```
165
-
166
- `std::llround()` correctly rounds both positive and negative numbers (rounds away from zero).
167
-
168
- **Why this matters:** Wrong rounding = wrong prices = wrong PnL = wrong risk calculations. Off-by-one tick errors compound over thousands of trades.
169
-
170
- ---
171
-
172
- ## BUG #6 — spread() Returns Garbage for Empty or Crossed Books
173
-
174
- **File:** `src/core/types.hpp` (line ~86)
175
- **What was the bug:**
176
- The `spread()` function calculated spread without checking if bids/asks existed:
177
-
178
- ```cpp
179
- double spread() const {
180
- return from_price(best_ask() - best_bid());
181
- }
182
- ```
183
-
184
- If `bids` is empty, `best_bid()` returns 0. If `asks` is empty, `best_ask()` returns 0. So `spread()` would return `from_price(0 - 0) = 0` which is accidentally correct for empty books, BUT if you have a crossed book (best_bid > best_ask, which can happen during fast markets), you'd get a negative spread which makes no sense.
185
-
186
- **How I fixed it:**
187
- ```cpp
188
- double spread() const {
189
- if (bids.empty() || asks.empty()) return 0.0;
190
- Price s = best_ask() - best_bid();
191
- return s > 0 ? from_price(s) : 0.0;
192
- }
193
- ```
194
-
195
- **Why this matters:** Strategies use spread to decide whether to quote. A negative spread could cause strategies to place inverted orders.
196
-
197
- ---
198
-
199
- ## BUG #7 — on_tick() Lock Placed After the Real Work
200
-
201
- **File:** `src/core/simulator.cpp` (line ~78)
202
- **What was the bug:**
203
- The `on_tick()` function was supposed to update market prices and record the latency. But the lock was placed AFTER the `update_market_price()` call:
204
-
205
- ```cpp
206
- void ExchangeSimulator::on_tick(const MarketDataTick& tick) {
207
- int64_t t0 = now_ns();
208
- risk_.update_market_price(tick.symbol, from_price(tick.last_price)); // ← NO LOCK
209
- std::lock_guard<std::mutex> lock(mtx_); // ← lock acquired AFTER the work
210
- tick_lat_.record(now_ns() - t0); // ← latency includes lock wait time
211
- }
212
- ```
213
-
214
- Two problems:
215
- 1. `update_market_price()` runs without any protection from the simulator's mutex, so if another thread calls `submit_order()` at the same time, you have a data race on the position's unrealized PnL.
216
- 2. The latency recorded includes the time spent waiting for the lock, which makes the metric useless — it measures contention, not processing speed.
217
-
218
- **How I fixed it:**
219
- ```cpp
220
- void ExchangeSimulator::on_tick(const MarketDataTick& tick) {
221
- int64_t t0 = now_ns();
222
- std::lock_guard<std::mutex> lock(mtx_); // ← lock FIRST
223
- risk_.update_market_price(tick.symbol, from_price(tick.last_price));
224
- tick_lat_.record(now_ns() - t0);
225
- }
226
- ```
227
-
228
- **Why this matters:** Data races cause undefined behavior — corrupted PnL numbers, positions that don't add up, and intermittent crashes that are nearly impossible to reproduce.
229
-
230
- ---
231
-
232
- ## BUG #8 — symbols() Missing Lock (Data Race)
233
-
234
- **File:** `src/core/simulator.cpp` (line ~92)
235
- **What was the bug:**
236
- The `symbols()` function iterates over the `books_` map to return a list of symbol names. But it didn't acquire the mutex:
237
-
238
- ```cpp
239
- std::vector<std::string> ExchangeSimulator::symbols() const {
240
- std::vector<std::string> s;
241
- for (auto& [k, _] : books_) s.push_back(k); // ← iterating without lock
242
- return s;
243
- }
244
- ```
245
-
246
- If another thread calls `add_symbol()` at the same time (which DOES hold the lock and modifies `books_`), you get a data race. On `std::unordered_map`, concurrent read + write is undefined behavior — it can crash, return garbage, or corrupt the map's internal hash table.
247
-
248
- **How I fixed it:**
249
- ```cpp
250
- std::vector<std::string> ExchangeSimulator::symbols() const {
251
- std::lock_guard<std::mutex> lock(mtx_); // ← added lock
252
- std::vector<std::string> s;
253
- for (auto& [k, _] : books_) s.push_back(k);
254
- return s;
255
- }
256
- ```
257
-
258
- **Why this matters:** Same as above — data races are undefined behavior in C++.
259
-
260
- ---
261
-
262
- ## BUG #9 — Rate Limiter Counts Rejected Orders (DoS Vulnerability)
263
-
264
- **File:** `src/risk/risk_manager.cpp` (line ~22)
265
- **What was the bug:**
266
- The old code recorded the order timestamp at the TOP of `check_order()`, BEFORE any validation:
267
-
268
- ```cpp
269
- RiskResult RiskManager::check_order(const Order& order, double current_price) {
270
- std::lock_guard<std::mutex> lock(mtx_);
271
- ++stats_checked_;
272
- order_timestamps_ns_.push_back(now_ns()); // ← counted even if rejected
273
- // ... then check qty, price, position, etc.
274
- }
275
- ```
276
-
277
- This means: if someone sends 2000 orders with `qty = -1` (which gets rejected immediately), all 2000 are counted toward the rate limit. Now the rate limit is exhausted, and VALID orders from a legitimate strategy also get rejected for the next second.
278
-
279
- This is a denial-of-service attack vector — a rogue client can disable all trading by spamming invalid orders.
280
-
281
- **How I fixed it:**
282
- Moved the timestamp recording to AFTER all validation checks pass:
283
-
284
- ```cpp
285
- RiskResult RiskManager::check_order(const Order& order, double current_price) {
286
- std::lock_guard<std::mutex> lock(mtx_);
287
- ++stats_checked_;
288
-
289
- // All validation checks first...
290
- if (order.qty <= 0) return REJECT;
291
- if (order.price <= 0) return REJECT;
292
- // ... position, notional, rate checks ...
293
-
294
- // Only count approved orders toward rate limit
295
- order_timestamps_ns_.push_back(now_ns());
296
- return {true, "OK"};
297
- }
298
- ```
299
-
300
- Also changed the rate check from `>` to `>=` (off-by-one: the old code allowed `max + 1` orders per second).
301
-
302
- **Why this matters:** In a real exchange, a rate limiter that counts rejected orders is a security vulnerability. Any client could freeze trading for everyone.
303
-
304
- ---
305
-
306
- ## BUG #10 — JSON Injection in Error Response (Command Name)
307
-
308
- **File:** `src/engine_cli.cpp` (line ~285)
309
- **What was the bug:**
310
- When an unknown command was received, the error message included the raw command name without JSON escaping:
311
-
312
- ```cpp
313
- return "{\"error\":\"unknown command: " + cmd + "\"}";
314
- ```
315
-
316
- If someone sends a command like `test"injected":"value`, the output becomes:
317
- ```json
318
- {"error":"unknown command: test"injected":"value"}
319
- ```
320
-
321
- This is malformed JSON. The Python side does `json.loads()` on this response, which would throw an exception, causing a 500 error instead of a clean error message.
322
-
323
- **How I fixed it:**
324
- ```cpp
325
- return "{\"error\":" + J::str("unknown command: " + cmd) + "}";
326
- ```
327
-
328
- `J::str()` properly escapes quotes and backslashes inside the string.
329
-
330
- **Why this matters:** Malformed JSON breaks the entire Python-C++ communication protocol. Any unusual input could crash the API.
331
-
332
- ---
333
-
334
- ## BUG #11 — JSON Injection in Exception Message
335
-
336
- **File:** `src/engine_cli.cpp` (line ~307)
337
- **What was the bug:**
338
- Same problem as Bug #10, but in the exception handler:
339
-
340
- ```cpp
341
- catch (const std::exception& e) {
342
- std::cout << "{\"error\":\"" << e.what() << "\"}\n";
343
- }
344
- ```
345
-
346
- If `e.what()` contains a quote character (which many standard library exceptions do), the JSON output is malformed.
347
-
348
- **How I fixed it:**
349
- ```cpp
350
- catch (const std::exception& e) {
351
- std::cout << "{\"error\":" << J::str(e.what()) << "}\n";
352
- }
353
- ```
354
-
355
- **Why this matters:** Same as Bug #10 — any exception with a quote in the message would break JSON parsing.
356
-
357
- ---
358
-
359
- ## BUG #12 — Out-of-Bounds Read in jget() (engine_cli.cpp)
360
-
361
- **File:** `src/engine_cli.cpp` (line ~22-23)
362
- **What was the bug:**
363
- The `jget()` JSON parser skips whitespace after the colon:
364
-
365
- ```cpp
366
- while (pos < body.size() && (body[pos] == ' ' || body[pos] == '\t')) ++pos;
367
- if (body[pos] == '"') { // ← if pos == body.size(), this is OUT OF BOUNDS
368
- ```
369
-
370
- After the `while` loop, `pos` could be equal to `body.size()`. Accessing `body[pos]` when `pos == body.size()` reads past the end of the string. In C++, this is undefined behavior — it could read garbage memory, crash, or appear to work.
371
-
372
- **How I fixed it:**
373
- ```cpp
374
- while (pos < body.size() && (body[pos] == ' ' || body[pos] == '\t')) ++pos;
375
- if (pos >= body.size()) return ""; // ← added bounds check
376
- if (body[pos] == '"') {
377
- ```
378
-
379
- **Why this matters:** Out-of-bounds reads are undefined behavior. They can be exploited for information disclosure or cause crashes.
380
-
381
- ---
382
-
383
- ## BUG #13 — Out-of-Bounds Read in json_get() (main.cpp)
384
-
385
- **File:** `src/main.cpp` (line ~56-57)
386
- **What was the bug:**
387
- Exact same bug as Bug #12, but in the `json_get()` function in `main.cpp` (the HTTP server version). It's a copy-pasted function with the same flaw.
388
-
389
- **How I fixed it:**
390
- Same fix — added `if (pos >= body.size()) return "";` after the whitespace skip loop.
391
-
392
- **Why this matters:** Same as Bug #12.
393
-
394
- ---
395
-
396
- ## BUG #14 — Feed Index Race Condition
397
-
398
- **File:** `src/main.cpp` (line ~240-241)
399
- **What was the bug:**
400
- The feed stepping code used an atomic counter but had a race in the reset logic:
401
-
402
- ```cpp
403
- size_t idx = g_feed_idx.fetch_add(1);
404
- if (idx >= g_tick_feed.size()) { g_feed_idx = 0; idx = 0; }
405
- ```
406
-
407
- The problem: Two threads can both see `idx >= size` at the same time. Both reset `g_feed_idx = 0`. Both use `idx = 0`. Now two requests replay the same tick. Also, `g_feed_idx = 0` is a non-atomic store on an atomic variable — technically a data race.
408
-
409
- **How I fixed it:**
410
- ```cpp
411
- size_t idx = g_feed_idx.fetch_add(1) % g_tick_feed.size();
412
- ```
413
-
414
- Using modulo (`%`) on the atomically-fetched value gives correct wraparound without any reset. Lock-free, race-free, one line.
415
-
416
- **Why this matters:** In an HFT context, replaying the same tick twice or skipping ticks means your backtest results are wrong.
417
-
418
- ---
419
-
420
- ## BUG #15 — Dead Code / No-Op Expression
421
-
422
- **File:** `app.py`
423
- **What was the bug:**
424
- There was a line that looked like it was trying to do something but was actually a no-op expression that Python evaluated and threw away:
425
-
426
- ```python
427
- self.proc.stdout._sock if hasattr(self.proc.stdout, '_sock') else None
428
- ```
429
-
430
- This evaluates to either a socket object or `None`, but the result is never assigned to anything or used. It does nothing.
431
-
432
- **How I fixed it:** Removed the dead code entirely.
433
-
434
- **Why this matters:** Dead code confuses future developers and suggests the original author didn't understand what they were writing.
435
-
436
- ---
437
-
438
- ## BUG #16 — depth Parameter Not Forwarded
439
-
440
- **File:** `app.py` (line ~191)
441
- **What was the bug:**
442
- The `/api/book/{symbol}` endpoint accepted a `depth` query parameter, but never actually sent it to the C++ engine:
443
-
444
- ```python
445
- @app.get("/api/book/{symbol}")
446
- def book(symbol: str, depth: int = 10):
447
- return _get_engine().send("book", {"symbol": symbol}) # ← depth is ignored!
448
- ```
449
-
450
- So no matter what depth the user requested, they always got the default 10 levels.
451
-
452
- **How I fixed it:**
453
- ```python
454
- return _get_engine().send("book", {"symbol": symbol, "depth": depth})
455
- ```
456
-
457
- **Why this matters:** Silent parameter ignoring is a bug that erodes API trust. Users think they're controlling the depth but they're not.
458
-
459
- ---
460
-
461
- ## BUG #17 — Dangling Declaration (Linker Bomb)
462
-
463
- **File:** `src/core/simulator.hpp` (line ~104)
464
- **What was the bug:**
465
- The `Backtester` class declared a method `replay_journal(const std::string& path)` in the header, but the implementation (`.cpp` file) never defined it:
466
-
467
- ```cpp
468
- class Backtester {
469
- public:
470
- // ...
471
- BacktestResult replay_journal(const std::string& path); // ← declared but never defined
472
- };
473
- ```
474
-
475
- Right now, nothing calls this function, so there's no linker error. But if anyone ever tries to call `bt.replay_journal("file.journal")`, they'll get a cryptic linker error: `undefined reference to Backtester::replay_journal`. This is a maintenance trap.
476
-
477
- **How I fixed it:** Removed the declaration entirely.
478
-
479
- **Why this matters:** Dangling declarations are tech debt that confuses future developers and causes linker errors when someone tries to use the function.
480
-
481
- ---
482
-
483
- ## BONUS FIXES (Cleanup)
484
-
485
- - **Removed unused imports** in `app.py`: `asyncio`, `StaticFiles`, `JSONResponse`, `time` — none were used anywhere in the file.
486
- - **Added `self.proc = None`** before restart in error paths — ensures the old dead process handle doesn't leak.
487
-
488
- ---
489
-
490
- ## Summary by File
491
-
492
- | File | Bugs Fixed | Bug Numbers |
493
- |------|-----------|-------------|
494
- | `app.py` | 7 | #1, #2, #3, #15, #16, +cleanup |
495
- | `src/core/types.hpp` | 2 | #5, #6 |
496
- | `src/core/order_book.cpp` | 1 | #4 |
497
- | `src/core/simulator.cpp` | 2 | #7, #8 |
498
- | `src/core/simulator.hpp` | 1 | #17 |
499
- | `src/risk/risk_manager.cpp` | 1 | #9 |
500
- | `src/engine_cli.cpp` | 3 | #10, #11, #12 |
501
- | `src/main.cpp` | 2 | #13, #14 |
502
-
503
- ---
504
-
505
- ## Summary by Severity
506
-
507
- | Severity | Count | Bug Numbers |
508
- |----------|-------|-------------|
509
- | 🔴 CRITICAL (crash/UB) | 4 | #1, #2, #3, #4 |
510
- | 🟡 MEDIUM (wrong behavior) | 9 | #5, #6, #7, #8, #9, #10, #11, #12, #13, #14 |
511
- | 🟢 LOW (cleanup/maintenance) | 4 | #15, #16, #17, +imports |