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.
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/workflows/release.yml +18 -5
- dbxdebug-0.4.0/.release-please-manifest.json +3 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/CHANGELOG.md +31 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/PKG-INFO +136 -48
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/README.md +135 -47
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/docs/migration.md +135 -79
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/pyproject.toml +5 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/SKILL.md +76 -53
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/references/recipes.md +61 -34
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/debug-dos-programs/references/troubleshooting.md +95 -67
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/history.md +13 -4
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/__init__.py +4 -1
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/cli.py +80 -14
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/frames.py +12 -9
- dbxdebug-0.4.0/src/dbxdebug/gdb.py +804 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/qmp.py +63 -5
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/session.py +209 -24
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/video.py +121 -44
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/conftest.py +14 -11
- dbxdebug-0.4.0/tests/integration/test_headless.py +282 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/test_live_session.py +268 -37
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_cli_session.py +10 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_gdb_capabilities.py +10 -0
- dbxdebug-0.4.0/tests/test_gdb_framing.py +436 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_gdb_registers.py +10 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_qmp_commands.py +3 -0
- dbxdebug-0.4.0/tests/test_read_bulk.py +325 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_session.py +69 -2
- dbxdebug-0.4.0/tests/test_video_client_ownership.py +345 -0
- dbxdebug-0.3.0/.release-please-manifest.json +0 -3
- dbxdebug-0.3.0/src/dbxdebug/gdb.py +0 -439
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/dependabot.yml +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.github/workflows/test.yml +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.gitignore +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/.pre-commit-config.yaml +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/LICENSE +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/plugin.json +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/release-please-config.json +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/SKILL.md +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/packets.md +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/skills/dosbox-x-debug-protocol/references/qmp-commands.md +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/addressing.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/capture_io.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/dbx_kbd.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/doctor.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/html.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/keyboard.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/paths.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/registry.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/src/dbxdebug/utils.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/__init__.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/integration/__init__.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_addressing.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_basic.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_frames.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_paths.py +0 -0
- {dbxdebug-0.3.0 → dbxdebug-0.4.0}/tests/test_registry.py +0 -0
- {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@
|
|
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
|
|
@@ -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
|
+
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
|
-
#
|
|
76
|
-
|
|
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=`, `
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
```
|
|
465
|
+
from dbxdebug.cli import GDB_CLIENT_KEY, main
|
|
466
|
+
from dbxdebug.video import DOSVideoTools
|
|
381
467
|
|
|
382
|
-
|
|
383
|
-
(
|
|
384
|
-
|
|
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
|
-
|
|
398
|
-
([
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 --
|
|
429
|
-
|
|
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
|
-
#
|
|
54
|
-
|
|
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=`, `
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
```
|
|
443
|
+
from dbxdebug.cli import GDB_CLIENT_KEY, main
|
|
444
|
+
from dbxdebug.video import DOSVideoTools
|
|
359
445
|
|
|
360
|
-
|
|
361
|
-
(
|
|
362
|
-
|
|
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
|
-
|
|
376
|
-
([
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 --
|
|
407
|
-
|
|
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
|