dbxdebug 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/workflows/release.yml +18 -5
  2. dbxdebug-0.4.0/.release-please-manifest.json +3 -0
  3. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/CHANGELOG.md +31 -0
  4. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/PKG-INFO +136 -48
  5. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/README.md +135 -47
  6. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/docs/migration.md +135 -79
  7. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/pyproject.toml +5 -0
  8. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/SKILL.md +76 -53
  9. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/references/recipes.md +61 -34
  10. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/references/troubleshooting.md +95 -67
  11. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/history.md +13 -4
  12. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/__init__.py +4 -1
  13. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/cli.py +80 -14
  14. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/frames.py +12 -9
  15. dbxdebug-0.4.0/src/dbxdebug/gdb.py +804 -0
  16. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/qmp.py +63 -5
  17. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/session.py +209 -24
  18. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/video.py +121 -44
  19. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/conftest.py +14 -11
  20. dbxdebug-0.4.0/tests/integration/test_headless.py +282 -0
  21. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/test_live_session.py +268 -37
  22. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_cli_session.py +10 -0
  23. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_gdb_capabilities.py +10 -0
  24. dbxdebug-0.4.0/tests/test_gdb_framing.py +436 -0
  25. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_gdb_registers.py +10 -0
  26. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_qmp_commands.py +3 -0
  27. dbxdebug-0.4.0/tests/test_read_bulk.py +325 -0
  28. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_session.py +69 -2
  29. dbxdebug-0.4.0/tests/test_video_client_ownership.py +345 -0
  30. dbxdebug-0.3.0/.release-please-manifest.json +0 -3
  31. dbxdebug-0.3.0/src/dbxdebug/gdb.py +0 -439
  32. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/dependabot.yml +0 -0
  33. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/workflows/test.yml +0 -0
  34. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.gitignore +0 -0
  35. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.pre-commit-config.yaml +0 -0
  36. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/LICENSE +0 -0
  37. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/plugin.json +0 -0
  38. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/release-please-config.json +0 -0
  39. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/SKILL.md +0 -0
  40. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/packets.md +0 -0
  41. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/qmp-commands.md +0 -0
  42. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/addressing.py +0 -0
  43. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/capture_io.py +0 -0
  44. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/dbx_kbd.py +0 -0
  45. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/doctor.py +0 -0
  46. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/html.py +0 -0
  47. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/keyboard.py +0 -0
  48. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/paths.py +0 -0
  49. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/registry.py +0 -0
  50. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/utils.py +0 -0
  51. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/__init__.py +0 -0
  52. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/__init__.py +0 -0
  53. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_addressing.py +0 -0
  54. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_basic.py +0 -0
  55. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_frames.py +0 -0
  56. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_paths.py +0 -0
  57. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_registry.py +0 -0
  58. {dbxdebug-0.3.0 → dbxdebug-0.4.0}/uv.lock +0 -0
@@ -9,6 +9,18 @@ name: Release
9
9
  on:
10
10
  push:
11
11
  branches: [main]
12
+ # Recovery path. A tag/release created with GITHUB_TOKEN does not trigger a
13
+ # workflow, so a publish that fails for an environmental reason (a bad
14
+ # publisher/metadata pairing, a PyPI outage) cannot be retried by re-running
15
+ # the original run -- a re-run replays the workflow file from that commit,
16
+ # including whatever was broken. Dispatch this against the existing tag
17
+ # instead. It rebuilds from that tag and republishes; it never creates a tag.
18
+ workflow_dispatch:
19
+ inputs:
20
+ tag:
21
+ description: "Existing tag to build and publish, e.g. v0.3.0"
22
+ required: true
23
+ type: string
12
24
 
13
25
  permissions: {}
14
26
 
@@ -18,6 +30,7 @@ concurrency:
18
30
 
19
31
  jobs:
20
32
  release-please:
33
+ if: ${{ github.event_name == 'push' }}
21
34
  runs-on: ubuntu-latest
22
35
  permissions:
23
36
  contents: write # create the release + tag
@@ -36,14 +49,14 @@ jobs:
36
49
  # If anything here fails, the publish job below is skipped and nothing ships.
37
50
  ci:
38
51
  needs: release-please
39
- if: ${{ needs.release-please.outputs.release_created == 'true' }}
52
+ if: ${{ !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }}
40
53
  permissions:
41
54
  contents: read
42
55
  uses: ./.github/workflows/test.yml
43
56
 
44
57
  publish:
45
58
  needs: [release-please, ci]
46
- if: ${{ needs.release-please.outputs.release_created == 'true' }}
59
+ if: ${{ !cancelled() && needs.ci.result == 'success' && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }}
47
60
  runs-on: ubuntu-latest
48
61
  environment:
49
62
  name: pypi
@@ -54,7 +67,7 @@ jobs:
54
67
  steps:
55
68
  - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
56
69
  with:
57
- ref: ${{ needs.release-please.outputs.tag_name }}
70
+ ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
58
71
  fetch-depth: 0 # Full history + tags for hatch-vcs version
59
72
 
60
73
  - name: Install uv and set up Python
@@ -66,9 +79,9 @@ jobs:
66
79
  run: uv build
67
80
 
68
81
  - name: Publish to PyPI
69
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
82
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
70
83
 
71
84
  - name: Attach artifacts to the GitHub Release
72
85
  env:
73
86
  GH_TOKEN: ${{ github.token }}
74
- run: gh release upload "${{ needs.release-please.outputs.tag_name }}" dist/* --clobber
87
+ run: gh release upload "${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}" dist/* --clobber
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.0"
3
+ }
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/lokkju/dbxdebug/compare/v0.3.0...v0.4.0) (2026-09-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **session:** DosboxSession now runs the emulator headless by default. A caller that relied on seeing the window -- to watch the guest while debugging, or to drive it by hand -- must now pass headless=False explicitly. Everything else is unaffected: the debug surface is identical headless, and screen capture was verified byte-identical between the two modes. Note also that `headless` sits mid-dataclass, so positional construction past `connect=` shifts.
9
+
10
+ ### Features
11
+
12
+ * **session:** add DosboxSession(headless=True), on by default ([8af1f56](https://github.com/lokkju/dbxdebug/commit/8af1f56528977c2dbc4a2ddec442f4aa6f76933d)), closes [#3](https://github.com/lokkju/dbxdebug/issues/3)
13
+ * **session:** add read_bulk, a one-call bulk memory read ([f8e228b](https://github.com/lokkju/dbxdebug/commit/f8e228b101b14e682e3b4ec98b434f1fd7e5e7c7)), closes [#9](https://github.com/lokkju/dbxdebug/issues/9)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **ci:** unblock publishing and add a recovery path for a failed one ([9c69a87](https://github.com/lokkju/dbxdebug/commit/9c69a87c73f30b0a249e1bd5ec3c4cc4f2af0736))
19
+ * **gdb:** bound reads and resynchronise the packet stream ([5940242](https://github.com/lokkju/dbxdebug/commit/5940242035181668c4d35e61db56d5b2342aa620))
20
+ * **session:** record that headless is now the default ([d649d70](https://github.com/lokkju/dbxdebug/commit/d649d705bb46261d38c30a23fc9c39bd390f7c87))
21
+ * **video,cli:** let a GDB client be borrowed instead of reopened ([bb9d351](https://github.com/lokkju/dbxdebug/commit/bb9d35170283407f9c7fbd64f4cfd4f1d75f1d64))
22
+
23
+
24
+ ### Performance Improvements
25
+
26
+ * **clients:** set TCP_NODELAY, and correct what read_bulk is now worth ([c535e3b](https://github.com/lokkju/dbxdebug/commit/c535e3bafe2799e708714e4f771046fd35a55d81))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * correct the hazard write-ups now that the GDB stream is fixed ([a1821aa](https://github.com/lokkju/dbxdebug/commit/a1821aa8904987afd73ad1ba97d022fee5e30213)), closes [#4](https://github.com/lokkju/dbxdebug/issues/4) [#5](https://github.com/lokkju/dbxdebug/issues/5)
32
+ * **migration:** fix the gaps a real migration hit ([96ffaa7](https://github.com/lokkju/dbxdebug/commit/96ffaa7a901a35529e14a81828f8a36d550664d9))
33
+
3
34
  ## [0.3.0](https://github.com/lokkju/dbxdebug/compare/v0.2.1...v0.3.0) (2026-09-05)
4
35
 
5
36
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dbxdebug
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Client library and CLI for DOSBox-X remote debug protocols (GDB and QMP)
5
5
  Author: lokkju
6
6
  License-Expression: LicenseRef-Polyform-Shield-1.0.0
@@ -72,10 +72,9 @@ For working on dbxdebug itself, `uv sync`.
72
72
  ```python
73
73
  from dbxdebug.session import DosboxSession
74
74
 
75
- # Without these DOSBox-X opens a real window and takes your keyboard focus.
76
- HEADLESS = {"SDL_VIDEODRIVER": "dummy", "SDL_AUDIODRIVER": "dummy"}
77
-
78
- with DosboxSession(env=HEADLESS) as session:
75
+ # Headless by default: no window, no keyboard focus, no audio device.
76
+ # Pass headless=False when you want to watch the guest.
77
+ with DosboxSession() as session:
79
78
  print(f"pid={session.pid} gdb={session.gdb_port} qmp={session.qmp_port}")
80
79
 
81
80
  gdb = session.gdb
@@ -103,15 +102,56 @@ Leaving the `with` block kills the emulator's whole process group, deletes the
103
102
  scratch workdir, and removes the registry entry. `atexit` and SIGINT/SIGTERM
104
103
  handlers repeat that teardown for a process that leaves by some other door.
105
104
 
105
+ ### Headless is the default
106
+
107
+ A session runs with SDL's `dummy` video and audio drivers, so it opens **no
108
+ window**, takes no keyboard focus, and claims no audio device. Starting one
109
+ does not take over the display of whoever is at the machine, which is what
110
+ makes a test suite -- or several sessions at once -- usable on a workstation.
111
+
112
+ What you give up is real: you cannot watch the guest. `headless=False` gives
113
+ you a normal window, and that window will take the focus of whoever is at the
114
+ keyboard, so it is for looking at one session, not for a suite.
115
+
116
+ The debug surface is unaffected. `screen_lines()` reads guest video memory
117
+ over GDB and `qmp.screendump()` renders through DOSBox-X's own capture path;
118
+ `dummy` removes the window, not the rendering. Compared live, a headless
119
+ session and a windowed session showing the same screen returned
120
+ **byte-identical** 720x400 PNGs and identical text lines.
121
+
122
+ `env=` still works and takes precedence over the headless variables, so
123
+ `DosboxSession(headless=True, env={"SDL_VIDEODRIVER": "x11"})` keeps the dummy
124
+ audio driver and gets an x11 window. Precedence, lowest to highest: inherited
125
+ `os.environ`, then the headless variables, then `env`.
126
+
106
127
  Useful `DosboxSession` arguments: `mounts={"c": path}`, `program=` and
107
128
  `files=` to stage host files onto the first mounted drive, `autoexec=`,
108
- `conf=` for your own conf template, `cycles=`, `connect=False` for the handle
109
- without clients, `boot_settle=` (default 2.5s -- the debug ports accept long
110
- before the guest reaches a prompt), and `label=` to name the scratch workdir.
111
- Conveniences on the handle: `screen_lines()`, `wait_for_text()`,
129
+ `conf=` for your own conf template, `cycles=`, `headless=False` for a visible
130
+ window, `connect=False` for the handle without clients, `boot_settle=`
131
+ (default 2.5s -- the debug ports accept long before the guest reaches a
132
+ prompt), and `label=` to name the scratch workdir.
133
+ Conveniences on the handle: `screen_lines()`, `read_bulk()`, `wait_for_text()`,
112
134
  `assert_screen_readable()`, `running`, `set_breakpoint()`,
113
135
  `remove_breakpoint()`.
114
136
 
137
+ ### Reading a region: `session.read_bulk(address, length)`
138
+
139
+ One QMP `memdump` reply instead of thousands of GDB `m` round-trips.
140
+ Measured on this build, one 64 KB segment: **2.8 ms** through `read_bulk` against **33.5 ms** for the same bytes through 64 one-kilobyte `gdb.read_memory` calls -- 12x against a running guest, and 2.6x (2.1 ms against 5.5 ms) when the CPU is already halted, where the loop has no emulation competing with it. Both paths return
141
+ identical bytes; the live suite asserts it.
142
+
143
+ ```python
144
+ data = session.read_bulk(0xF0000, 0x10000) # -> 65536 bytes
145
+ ```
146
+
147
+ It exists because the raw sequence has two traps. `memdump` is refused
148
+ while the CPU runs, and the obvious way to stop it -- `qmp.stop()` --
149
+ parks the emulation thread that services the GDB stub, so the dump
150
+ succeeds and every later GDB request goes unanswered. `read_bulk` halts
151
+ through GDB, dumps, and resumes. If the CPU was ALREADY stopped (a
152
+ breakpoint, the interactive debugger, or a QMP stop) it takes the dump
153
+ and leaves it stopped: it resumes only what it halted itself.
154
+
115
155
  ## Addressing -- read this once
116
156
 
117
157
  This is the most consequential behaviour in the package, and the thing most
@@ -182,7 +222,7 @@ old build fails at `start()` rather than at the first breakpoint.
182
222
  |---|---|
183
223
  | `dbxdebug.session` | `DosboxSession` -- launch, connect, tear down. Also `DEFAULT_CONF`, `render_conf`, `DosboxLaunchError` |
184
224
  | `dbxdebug.gdb` | `GDBClient`, `IncompatibleStubError`, `REGISTER_NAMES` |
185
- | `dbxdebug.qmp` | `QMPClient`, `QMPError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
225
+ | `dbxdebug.qmp` | `QMPClient`, `QMPError`, `CpuNotStoppedError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
186
226
  | `dbxdebug.addressing` | `linear`, `linear_pc`, `parse_address`, `bp_addr`, `PackedAddressError` |
187
227
  | `dbxdebug.frames` | `walk_frames`, `steps_out`, `Frame`, `FrameWalkError` |
188
228
  | `dbxdebug.registry` | `list_sessions`, `reap`, `format_table`, `free_port`, `kill_group` |
@@ -244,8 +284,10 @@ itself reaches, not the `pop bp` or `leave` before it. Consequences:
244
284
  * called at a procedure's first instruction, before the prologue has run, BP
245
285
  still belongs to the caller and this measures the caller's frame;
246
286
  * **clear every breakpoint first.** A breakpoint hit during one of these steps
247
- makes the stub emit an unsolicited stop reply, which permanently desyncs the
248
- GDB connection -- see Known hazards.
287
+ makes the stub emit an unsolicited stop reply. The connection no longer
288
+ desyncs on one -- it is queued on `gdb.pending_stops` -- but the stop you
289
+ get is still not the step you asked for, so the walk ends up measuring a
290
+ frame you did not mean to be in. See Known hazards.
249
291
 
250
292
  ## CLI
251
293
 
@@ -357,51 +399,88 @@ with QMPClient() as qmp: # localhost:4444
357
399
  qmp.send_key(CTRL_C)
358
400
  qmp.type_text("Hello World!")
359
401
 
360
- with DOSVideoTools() as video:
402
+ with DOSVideoTools() as video: # owns its own client
361
403
  lines = video.screen_dump()
362
404
  lines, ticks = video.screen_dump_with_ticks()
405
+
406
+ with DOSVideoTools(gdb=session.gdb) as video: # borrows a session's client
407
+ lines = video.screen_dump() # and never closes it
363
408
  ```
364
409
 
365
410
  ## Known hazards
366
411
 
367
- Three open defects. All three are reproduced, all have tests pinning today's
368
- behaviour, and none is fixed. Plan around them.
412
+ One open defect, and two that are fixed but still shape how you should write
413
+ against this library.
414
+
415
+ **Unanswered GDB packets: bounded, not silent** (was
416
+ [#4](https://github.com/lokkju/dbxdebug/issues/4), fixed). `GDBClient` arms a
417
+ 30 s read timeout on every read, not just the connect, and raises
418
+ `GDBTimeoutError` naming the packet that went unanswered. Override it per
419
+ client with `GDBClient(timeout=...)`, or pass `timeout=None` for the old
420
+ unbounded blocking. The underlying interaction is unchanged and still worth
421
+ knowing: while the emulator is QMP-stopped the GDB stub is not serviced at
422
+ all, so `qmp.stop()` followed by any GDB request cannot be answered. It now
423
+ fails in 30 s with a message instead of deadlocking. To read memory, reach
424
+ for `session.read_bulk()`, which halts over GDB for you; otherwise halt
425
+ with `gdb.halt()` rather than stopping over QMP. A `memdump` refused for
426
+ this reason now raises `CpuNotStoppedError` (a `QMPError`) naming the fix,
427
+ rather than surfacing the stub's refusal alone.
428
+
429
+ **Stream desync: resynchronised, or refused** (was
430
+ [#5](https://github.com/lokkju/dbxdebug/issues/5), fixed). Both triggers were
431
+ reproduced against a live build -- an unsolicited `$S05` stop reply (QMP
432
+ break-on-exec fires one nobody asked for) and a timed-out request leaving its
433
+ reply in the stream -- and both are handled at the framing layer:
434
+
435
+ * an unrequested stop reply is diverted to `gdb.pending_stops` instead of
436
+ being read as an answer. Drain it with `gdb.take_pending_stops()`; the
437
+ queue keeps the most recent 64. This is how you learn the CPU stopped
438
+ without polling QMP;
439
+ * an abandoned exchange is drained before the next packet is sent, so the
440
+ request after a `GDBTimeoutError` gets its own reply rather than the
441
+ previous one's;
442
+ * if that drain cannot complete, the client marks itself **permanently
443
+ unusable** and every later call raises `GDBDesyncError`. That is
444
+ deliberate: a loud failure beats a plausible wrong answer. Open a new
445
+ `GDBClient`.
446
+
447
+ Still true, and still worth doing: keep GDB traffic serialised on one thread,
448
+ and never add a read-retry loop. Two identical consecutive requests mask a
449
+ one-packet lag perfectly, so retrying would look like it worked whether or
450
+ not the stream had shifted.
451
+
452
+ **One GDB client at a time**
453
+ ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
454
+ GDB client. A second one completes the TCP connect and then never gets its
455
+ `qSupported` reply -- no refusal, and nothing on the wire. Two things changed
456
+ independently: it now fails after the read timeout instead of hanging forever
457
+ ([#5](https://github.com/lokkju/dbxdebug/issues/5)), and nothing in this
458
+ package opens a competing connection behind your back any more
459
+ ([#11](https://github.com/lokkju/dbxdebug/issues/11)). The stub limitation
460
+ itself is still open ([#8](https://github.com/lokkju/dbxdebug/issues/8)).
369
461
 
370
- **No read timeout** ([#4](https://github.com/lokkju/dbxdebug/issues/4)).
371
- `GDBClient` never calls `settimeout`, so any packet the stub does not answer
372
- hangs the caller forever with no diagnostic. This is easy to reach by
373
- accident, because the two protocols interact: while the emulator is
374
- QMP-stopped the GDB stub does not answer at all, so `qmp.stop()` followed by
375
- any GDB request is a deadlock. Arm the socket yourself right after `start()`:
462
+ Lend the session's client out instead of opening a second one:
376
463
 
377
464
  ```python
378
- if session.gdb is not None and session.gdb.sock is not None:
379
- session.gdb.sock.settimeout(30.0)
380
- ```
465
+ from dbxdebug.cli import GDB_CLIENT_KEY, main
466
+ from dbxdebug.video import DOSVideoTools
381
467
 
382
- **Desync after a timeout or an unsolicited stop reply**
383
- ([#5](https://github.com/lokkju/dbxdebug/issues/5) -- confirmed and
384
- reproduced). `GDBClient` assumes strict request/response and never
385
- resynchronises. Once a reply is left unread, every later request returns the
386
- *previous* request's payload, silently and permanently. Both triggers are
387
- confirmed against a live build: an unsolicited `$S05` stop reply (QMP
388
- break-on-exec fires one nobody asked for), and a timed-out request leaving its
389
- reply in the stream -- so the `settimeout` above converts a hang into a
390
- `TimeoutError` that lands you here instead. Keep GDB traffic serialised on one
391
- thread, treat `TimeoutError` as fatal to the connection rather than retryable,
392
- and check the length of every `read_memory` result against what you asked for:
393
- it is the one cheap symptom visible from outside. Do not add a read-retry
394
- loop -- two identical consecutive requests mask a one-packet lag perfectly, so
395
- it would look like it worked whether or not the stream had shifted.
468
+ with DosboxSession(...) as session:
469
+ with DOSVideoTools(gdb=session.gdb) as video: # borrowed, not reopened
470
+ lines = video.screen_dump()
396
471
 
397
- **One GDB client at a time**
398
- ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
399
- GDB client. A second one completes the TCP connect and then blocks forever in
400
- the `qSupported` handshake -- no refusal, no error, just a hang. In particular,
401
- pointing a `dbxdebug mem` / `cpu` / `screen` command at a session that already
402
- holds its own GDB client hangs that command. Use
403
- `DosboxSession(connect=False)` if you want the CLI to be the one client, or
404
- drive the session's own `session.gdb` from Python.
472
+ # the CLI, driven in-process, borrows the same client
473
+ main(["screen", "show"], obj={GDB_CLIENT_KEY: session.gdb}, standalone_mode=False)
474
+ ```
475
+
476
+ A borrowed client is never closed by the borrower -- the session stays its
477
+ owner. `DOSVideoTools()` with no `gdb=` still builds and closes its own, which
478
+ is the right thing when it is the only client, and so does a `dbxdebug` command
479
+ run as a separate process. Running the CLI as a separate process against a
480
+ session that holds its own client is still a second connection however it is
481
+ spelled, so it now fails on the timeout rather than working: use
482
+ `DosboxSession(connect=False)` there, or drive the session's `session.gdb`
483
+ from Python.
405
484
 
406
485
  QMP is a separate socket and is undisturbed by any of this, which is why
407
486
  `qmp.query_status()` is the way to learn that the CPU stopped.
@@ -425,11 +504,20 @@ prove the library actually drives one: the vendor GDB capabilities, `eip` as an
425
504
  offset rather than a linear address, a breakpoint above 64 KB firing, `memdump`
426
505
  agreeing with GDB reads and refusing while the CPU runs, `frames.steps_out`
427
506
  stopping after a real 16-bit `ret`, and what the GDB client does when the
428
- stream is disturbed -- which today is desync, pinned by tests that fail the
429
- moment it is fixed. The binary is located with `dbxdebug.paths.find_dosbox_x`
507
+ stream is disturbed -- an unrequested stop reply queued rather than read as an
508
+ answer, an abandoned reply drained rather than handed to the next request, and
509
+ a request the stub will never answer bounded rather than deadlocked. The
510
+ framing paths a live emulator will not produce on demand are covered against a
511
+ fake socket in `tests/test_gdb_framing.py`.
512
+ The binary is located with `dbxdebug.paths.find_dosbox_x`
430
513
  -- set `DBXDEBUG_DOSBOX` to choose a specific build -- and the tests skip when
431
514
  none is found.
432
515
 
516
+ One test in `tests/integration/test_headless.py` is skipped even under the
517
+ `integration` marker: the one that compares a headless screen capture against
518
+ a windowed one has to launch a real window, which takes the keyboard focus of
519
+ whoever is at the machine. Set `DBXDEBUG_ALLOW_WINDOWED=1` to run it.
520
+
433
521
  The other gates:
434
522
 
435
523
  ```bash
@@ -50,10 +50,9 @@ For working on dbxdebug itself, `uv sync`.
50
50
  ```python
51
51
  from dbxdebug.session import DosboxSession
52
52
 
53
- # Without these DOSBox-X opens a real window and takes your keyboard focus.
54
- HEADLESS = {"SDL_VIDEODRIVER": "dummy", "SDL_AUDIODRIVER": "dummy"}
55
-
56
- with DosboxSession(env=HEADLESS) as session:
53
+ # Headless by default: no window, no keyboard focus, no audio device.
54
+ # Pass headless=False when you want to watch the guest.
55
+ with DosboxSession() as session:
57
56
  print(f"pid={session.pid} gdb={session.gdb_port} qmp={session.qmp_port}")
58
57
 
59
58
  gdb = session.gdb
@@ -81,15 +80,56 @@ Leaving the `with` block kills the emulator's whole process group, deletes the
81
80
  scratch workdir, and removes the registry entry. `atexit` and SIGINT/SIGTERM
82
81
  handlers repeat that teardown for a process that leaves by some other door.
83
82
 
83
+ ### Headless is the default
84
+
85
+ A session runs with SDL's `dummy` video and audio drivers, so it opens **no
86
+ window**, takes no keyboard focus, and claims no audio device. Starting one
87
+ does not take over the display of whoever is at the machine, which is what
88
+ makes a test suite -- or several sessions at once -- usable on a workstation.
89
+
90
+ What you give up is real: you cannot watch the guest. `headless=False` gives
91
+ you a normal window, and that window will take the focus of whoever is at the
92
+ keyboard, so it is for looking at one session, not for a suite.
93
+
94
+ The debug surface is unaffected. `screen_lines()` reads guest video memory
95
+ over GDB and `qmp.screendump()` renders through DOSBox-X's own capture path;
96
+ `dummy` removes the window, not the rendering. Compared live, a headless
97
+ session and a windowed session showing the same screen returned
98
+ **byte-identical** 720x400 PNGs and identical text lines.
99
+
100
+ `env=` still works and takes precedence over the headless variables, so
101
+ `DosboxSession(headless=True, env={"SDL_VIDEODRIVER": "x11"})` keeps the dummy
102
+ audio driver and gets an x11 window. Precedence, lowest to highest: inherited
103
+ `os.environ`, then the headless variables, then `env`.
104
+
84
105
  Useful `DosboxSession` arguments: `mounts={"c": path}`, `program=` and
85
106
  `files=` to stage host files onto the first mounted drive, `autoexec=`,
86
- `conf=` for your own conf template, `cycles=`, `connect=False` for the handle
87
- without clients, `boot_settle=` (default 2.5s -- the debug ports accept long
88
- before the guest reaches a prompt), and `label=` to name the scratch workdir.
89
- Conveniences on the handle: `screen_lines()`, `wait_for_text()`,
107
+ `conf=` for your own conf template, `cycles=`, `headless=False` for a visible
108
+ window, `connect=False` for the handle without clients, `boot_settle=`
109
+ (default 2.5s -- the debug ports accept long before the guest reaches a
110
+ prompt), and `label=` to name the scratch workdir.
111
+ Conveniences on the handle: `screen_lines()`, `read_bulk()`, `wait_for_text()`,
90
112
  `assert_screen_readable()`, `running`, `set_breakpoint()`,
91
113
  `remove_breakpoint()`.
92
114
 
115
+ ### Reading a region: `session.read_bulk(address, length)`
116
+
117
+ One QMP `memdump` reply instead of thousands of GDB `m` round-trips.
118
+ Measured on this build, one 64 KB segment: **2.8 ms** through `read_bulk` against **33.5 ms** for the same bytes through 64 one-kilobyte `gdb.read_memory` calls -- 12x against a running guest, and 2.6x (2.1 ms against 5.5 ms) when the CPU is already halted, where the loop has no emulation competing with it. Both paths return
119
+ identical bytes; the live suite asserts it.
120
+
121
+ ```python
122
+ data = session.read_bulk(0xF0000, 0x10000) # -> 65536 bytes
123
+ ```
124
+
125
+ It exists because the raw sequence has two traps. `memdump` is refused
126
+ while the CPU runs, and the obvious way to stop it -- `qmp.stop()` --
127
+ parks the emulation thread that services the GDB stub, so the dump
128
+ succeeds and every later GDB request goes unanswered. `read_bulk` halts
129
+ through GDB, dumps, and resumes. If the CPU was ALREADY stopped (a
130
+ breakpoint, the interactive debugger, or a QMP stop) it takes the dump
131
+ and leaves it stopped: it resumes only what it halted itself.
132
+
93
133
  ## Addressing -- read this once
94
134
 
95
135
  This is the most consequential behaviour in the package, and the thing most
@@ -160,7 +200,7 @@ old build fails at `start()` rather than at the first breakpoint.
160
200
  |---|---|
161
201
  | `dbxdebug.session` | `DosboxSession` -- launch, connect, tear down. Also `DEFAULT_CONF`, `render_conf`, `DosboxLaunchError` |
162
202
  | `dbxdebug.gdb` | `GDBClient`, `IncompatibleStubError`, `REGISTER_NAMES` |
163
- | `dbxdebug.qmp` | `QMPClient`, `QMPError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
203
+ | `dbxdebug.qmp` | `QMPClient`, `QMPError`, `CpuNotStoppedError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
164
204
  | `dbxdebug.addressing` | `linear`, `linear_pc`, `parse_address`, `bp_addr`, `PackedAddressError` |
165
205
  | `dbxdebug.frames` | `walk_frames`, `steps_out`, `Frame`, `FrameWalkError` |
166
206
  | `dbxdebug.registry` | `list_sessions`, `reap`, `format_table`, `free_port`, `kill_group` |
@@ -222,8 +262,10 @@ itself reaches, not the `pop bp` or `leave` before it. Consequences:
222
262
  * called at a procedure's first instruction, before the prologue has run, BP
223
263
  still belongs to the caller and this measures the caller's frame;
224
264
  * **clear every breakpoint first.** A breakpoint hit during one of these steps
225
- makes the stub emit an unsolicited stop reply, which permanently desyncs the
226
- GDB connection -- see Known hazards.
265
+ makes the stub emit an unsolicited stop reply. The connection no longer
266
+ desyncs on one -- it is queued on `gdb.pending_stops` -- but the stop you
267
+ get is still not the step you asked for, so the walk ends up measuring a
268
+ frame you did not mean to be in. See Known hazards.
227
269
 
228
270
  ## CLI
229
271
 
@@ -335,51 +377,88 @@ with QMPClient() as qmp: # localhost:4444
335
377
  qmp.send_key(CTRL_C)
336
378
  qmp.type_text("Hello World!")
337
379
 
338
- with DOSVideoTools() as video:
380
+ with DOSVideoTools() as video: # owns its own client
339
381
  lines = video.screen_dump()
340
382
  lines, ticks = video.screen_dump_with_ticks()
383
+
384
+ with DOSVideoTools(gdb=session.gdb) as video: # borrows a session's client
385
+ lines = video.screen_dump() # and never closes it
341
386
  ```
342
387
 
343
388
  ## Known hazards
344
389
 
345
- Three open defects. All three are reproduced, all have tests pinning today's
346
- behaviour, and none is fixed. Plan around them.
390
+ One open defect, and two that are fixed but still shape how you should write
391
+ against this library.
392
+
393
+ **Unanswered GDB packets: bounded, not silent** (was
394
+ [#4](https://github.com/lokkju/dbxdebug/issues/4), fixed). `GDBClient` arms a
395
+ 30 s read timeout on every read, not just the connect, and raises
396
+ `GDBTimeoutError` naming the packet that went unanswered. Override it per
397
+ client with `GDBClient(timeout=...)`, or pass `timeout=None` for the old
398
+ unbounded blocking. The underlying interaction is unchanged and still worth
399
+ knowing: while the emulator is QMP-stopped the GDB stub is not serviced at
400
+ all, so `qmp.stop()` followed by any GDB request cannot be answered. It now
401
+ fails in 30 s with a message instead of deadlocking. To read memory, reach
402
+ for `session.read_bulk()`, which halts over GDB for you; otherwise halt
403
+ with `gdb.halt()` rather than stopping over QMP. A `memdump` refused for
404
+ this reason now raises `CpuNotStoppedError` (a `QMPError`) naming the fix,
405
+ rather than surfacing the stub's refusal alone.
406
+
407
+ **Stream desync: resynchronised, or refused** (was
408
+ [#5](https://github.com/lokkju/dbxdebug/issues/5), fixed). Both triggers were
409
+ reproduced against a live build -- an unsolicited `$S05` stop reply (QMP
410
+ break-on-exec fires one nobody asked for) and a timed-out request leaving its
411
+ reply in the stream -- and both are handled at the framing layer:
412
+
413
+ * an unrequested stop reply is diverted to `gdb.pending_stops` instead of
414
+ being read as an answer. Drain it with `gdb.take_pending_stops()`; the
415
+ queue keeps the most recent 64. This is how you learn the CPU stopped
416
+ without polling QMP;
417
+ * an abandoned exchange is drained before the next packet is sent, so the
418
+ request after a `GDBTimeoutError` gets its own reply rather than the
419
+ previous one's;
420
+ * if that drain cannot complete, the client marks itself **permanently
421
+ unusable** and every later call raises `GDBDesyncError`. That is
422
+ deliberate: a loud failure beats a plausible wrong answer. Open a new
423
+ `GDBClient`.
424
+
425
+ Still true, and still worth doing: keep GDB traffic serialised on one thread,
426
+ and never add a read-retry loop. Two identical consecutive requests mask a
427
+ one-packet lag perfectly, so retrying would look like it worked whether or
428
+ not the stream had shifted.
429
+
430
+ **One GDB client at a time**
431
+ ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
432
+ GDB client. A second one completes the TCP connect and then never gets its
433
+ `qSupported` reply -- no refusal, and nothing on the wire. Two things changed
434
+ independently: it now fails after the read timeout instead of hanging forever
435
+ ([#5](https://github.com/lokkju/dbxdebug/issues/5)), and nothing in this
436
+ package opens a competing connection behind your back any more
437
+ ([#11](https://github.com/lokkju/dbxdebug/issues/11)). The stub limitation
438
+ itself is still open ([#8](https://github.com/lokkju/dbxdebug/issues/8)).
347
439
 
348
- **No read timeout** ([#4](https://github.com/lokkju/dbxdebug/issues/4)).
349
- `GDBClient` never calls `settimeout`, so any packet the stub does not answer
350
- hangs the caller forever with no diagnostic. This is easy to reach by
351
- accident, because the two protocols interact: while the emulator is
352
- QMP-stopped the GDB stub does not answer at all, so `qmp.stop()` followed by
353
- any GDB request is a deadlock. Arm the socket yourself right after `start()`:
440
+ Lend the session's client out instead of opening a second one:
354
441
 
355
442
  ```python
356
- if session.gdb is not None and session.gdb.sock is not None:
357
- session.gdb.sock.settimeout(30.0)
358
- ```
443
+ from dbxdebug.cli import GDB_CLIENT_KEY, main
444
+ from dbxdebug.video import DOSVideoTools
359
445
 
360
- **Desync after a timeout or an unsolicited stop reply**
361
- ([#5](https://github.com/lokkju/dbxdebug/issues/5) -- confirmed and
362
- reproduced). `GDBClient` assumes strict request/response and never
363
- resynchronises. Once a reply is left unread, every later request returns the
364
- *previous* request's payload, silently and permanently. Both triggers are
365
- confirmed against a live build: an unsolicited `$S05` stop reply (QMP
366
- break-on-exec fires one nobody asked for), and a timed-out request leaving its
367
- reply in the stream -- so the `settimeout` above converts a hang into a
368
- `TimeoutError` that lands you here instead. Keep GDB traffic serialised on one
369
- thread, treat `TimeoutError` as fatal to the connection rather than retryable,
370
- and check the length of every `read_memory` result against what you asked for:
371
- it is the one cheap symptom visible from outside. Do not add a read-retry
372
- loop -- two identical consecutive requests mask a one-packet lag perfectly, so
373
- it would look like it worked whether or not the stream had shifted.
446
+ with DosboxSession(...) as session:
447
+ with DOSVideoTools(gdb=session.gdb) as video: # borrowed, not reopened
448
+ lines = video.screen_dump()
374
449
 
375
- **One GDB client at a time**
376
- ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
377
- GDB client. A second one completes the TCP connect and then blocks forever in
378
- the `qSupported` handshake -- no refusal, no error, just a hang. In particular,
379
- pointing a `dbxdebug mem` / `cpu` / `screen` command at a session that already
380
- holds its own GDB client hangs that command. Use
381
- `DosboxSession(connect=False)` if you want the CLI to be the one client, or
382
- drive the session's own `session.gdb` from Python.
450
+ # the CLI, driven in-process, borrows the same client
451
+ main(["screen", "show"], obj={GDB_CLIENT_KEY: session.gdb}, standalone_mode=False)
452
+ ```
453
+
454
+ A borrowed client is never closed by the borrower -- the session stays its
455
+ owner. `DOSVideoTools()` with no `gdb=` still builds and closes its own, which
456
+ is the right thing when it is the only client, and so does a `dbxdebug` command
457
+ run as a separate process. Running the CLI as a separate process against a
458
+ session that holds its own client is still a second connection however it is
459
+ spelled, so it now fails on the timeout rather than working: use
460
+ `DosboxSession(connect=False)` there, or drive the session's `session.gdb`
461
+ from Python.
383
462
 
384
463
  QMP is a separate socket and is undisturbed by any of this, which is why
385
464
  `qmp.query_status()` is the way to learn that the CPU stopped.
@@ -403,11 +482,20 @@ prove the library actually drives one: the vendor GDB capabilities, `eip` as an
403
482
  offset rather than a linear address, a breakpoint above 64 KB firing, `memdump`
404
483
  agreeing with GDB reads and refusing while the CPU runs, `frames.steps_out`
405
484
  stopping after a real 16-bit `ret`, and what the GDB client does when the
406
- stream is disturbed -- which today is desync, pinned by tests that fail the
407
- moment it is fixed. The binary is located with `dbxdebug.paths.find_dosbox_x`
485
+ stream is disturbed -- an unrequested stop reply queued rather than read as an
486
+ answer, an abandoned reply drained rather than handed to the next request, and
487
+ a request the stub will never answer bounded rather than deadlocked. The
488
+ framing paths a live emulator will not produce on demand are covered against a
489
+ fake socket in `tests/test_gdb_framing.py`.
490
+ The binary is located with `dbxdebug.paths.find_dosbox_x`
408
491
  -- set `DBXDEBUG_DOSBOX` to choose a specific build -- and the tests skip when
409
492
  none is found.
410
493
 
494
+ One test in `tests/integration/test_headless.py` is skipped even under the
495
+ `integration` marker: the one that compares a headless screen capture against
496
+ a windowed one has to launch a real window, which takes the keyboard focus of
497
+ whoever is at the machine. Set `DBXDEBUG_ALLOW_WINDOWED=1` to run it.
498
+
411
499
  The other gates:
412
500
 
413
501
  ```bash