Two releases, half a feature, one removal
v1.4 is the smallest release pair in chippy’s history. v1.4.0 adds one thing — a custom-request extension point in the DAP server. v1.4.1 removes one thing — the bundled VS Code extension that had been sitting in the repo since v0.4.0 and quietly tripping the release pipeline.
I want to write about both of them together because they happened the same day and they’re about the same underlying lesson: when one part of your release pipeline is consistently red, something has to give, and the right thing to give is usually whatever isn’t doing the work it was supposed to be doing.
The whole arc takes about 24 hours from “I have a clean v1.3.0 and a VS Code publish job that’s been failing for a week” to “v1.4.1 is out, the release pipeline is fully green for the first time since the breakage, and the v2.0 architectural work I keep meaning to start is finally unblocked.” Two releases, one removal, one new extension point, no architectural change beyond the four-line interface that lets a downstream consumer extend chippy’s DAP server without forking it.
v1.4.0: the custom-request hook
Let me do the v1.4.0 half first because it’s the smaller story and it leads into the v1.4.1 half cleanly.
The problem: DAP’s dispatch was a closed switch
The chippy DAP server’s request dispatcher is, structurally, a switch command in dap/server.go. Each handled
request — initialize, launch, setBreakpoints, continue, next, stepIn, variables, all the rest — gets
its own case arm. Unknown commands fall through to the default arm, which returns a JSON-RPC-shaped error:
{"success": false, "message": "not implemented", "command": "<command>"}
This is exactly the right behavior for a stock DAP implementation. A standard client (VS Code, nvim-dap, dape) that asks for a request the server doesn’t know about gets a clean “no” and can fall back. It is not the right behavior when you have a host — like nessy — that wants to layer its own debug commands on top of the protocol.
The thing nessy wanted, specifically, was a nessy/debugState channel that streamed PPU, OAM, mapper, and APU
state over the same DAP connection the regular debugger was already using. That data isn’t in the standard DAP
surface; there’s no getPPUScroll or peekOAM in the spec. nessy could have opened a second socket, run its own
JSON protocol, and let the editor mux the two. That would have worked, and it would also have meant every
custom debug surface needed a second connection. Two ports, two failure modes, two reconnect-on-attach scripts.
So nessy could either fork chippy’s dap package to add the requests it wanted, or chippy could add an extension
point. Forking is a bad time. The chippy dap package has dozens of files, a wire-protocol test corpus, and a
contract with the chippy TUI that’s been hardened over four months of releases. A nessy fork would diverge from
chippy’s mainline on day one and stay diverged forever; every chippy bugfix would be a manual cherry-pick.
The right answer is the extension point. v1.4.0 adds one.
The hook
// CustomRequestHandler runs at the tail of the DAP dispatcher under cpuMu when
// no built-in handler matches. Return handled=false to fall through to the
// standard "not implemented" error.
type CustomRequestHandler func(command string, args json.RawMessage) (body any, handled bool, err error)
type AttachConfig struct {
// ... existing fields ...
CustomRequestHandler CustomRequestHandler
}
That’s the whole API. The hook lives on AttachConfig, so a host opts in at the moment it constructs the
attach session. Inside Server.dispatch, the default arm of the switch consults the hook before returning
“not implemented”:
default:
if h := s.cfg.CustomRequestHandler; h != nil {
body, handled, err := h(req.Command, req.Arguments)
if handled {
s.respond(req, body, err)
return
}
}
s.respond(req, nil, fmt.Errorf("not implemented: %s", req.Command))
The handler is invoked under cpuMu. This is the load-bearing detail. The CPU mutex is what guarantees that
the handler reads coherent state — if you’re returning the PPU’s $2002 register from inside the handler, you
want the CPU not to be mid-STA while you read it. cpuMu is the contract from v1.1’s ownership model (server
owns CPU execution during a session) and the custom-request hook inherits it for free.
The other thing I want to call out: handled bool is a deliberately separate return value from err error.
handled = false means “I didn’t recognize the command, fall through to the standard error.” handled = true, err != nil means “I recognized the command, but it failed (bad args, peripheral disabled, whatever).” If I’d
collapsed those into a single channel — say, nil body means “not handled” — then a handler that intentionally
returns no body for a one-shot command would be indistinguishable from a handler that didn’t recognize the
command. The bool is the right shape. It costs one keyword in the signature and saves an entire category of bug.
What nessy does with it
Nessy registers a nessy/debugState handler that returns a JSON blob with the current PPU register file, OAM,
mapper state, and a few APU channel snapshots. The chippy TUI doesn’t know anything about this — it’s a request
that comes from nessy’s own debug panels (when nessy eventually grows them) or from any other nessy-aware client.
The protocol-extension pattern this establishes is the seam I expected to use exactly once for nessy and have
already used more times than that. The same hook is the natural place to add a chippy/perfgate debug request
later that returns inner-loop timing measurements without needing a separate chippy -profile invocation. It’s
also where the test harness for Source could mock peripheral state in a remote scenario. It’s a one-liner per
host, and it costs nothing on the wire if no host registers it.
So that’s v1.4.0. A four-line interface addition, an under-cpuMu invocation, a chore(vscode): pin @types/vscode follow-up that’s been in the queue for three weeks. Tiny release. Library-only consequences. No
TUI change.
Except, of course, the part about VS Code.
v1.4.1: the VS Code extension was always blocked
Here is the part where I have to back up and explain a thing about the chippy release pipeline that I haven’t written about yet, because it had been chugging along quietly in the background and there hadn’t been anything interesting to say.
Up through v1.3.0, every chippy release cut not just the binary tarballs and the Linux distro packages and the
Homebrew tap entry, but also a VS Code extension package — a .vsix archive uploaded to the marketplace under
the publisher Hidden-Pixel. The extension was simple. It registered chippy as a debug type, supplied a
DebugAdapterDescriptorFactory that spawned chippy -dap stdio, and got out of the way. Running it in VS Code
gave you source-level 6502 debugging with breakpoints, variables, the works. No third-party config required —
install the extension, point it at your .bin, hit F5.
That was the theory. In practice the marketplace publish job, vscode-extension in .github/workflows/release.yml,
had been failing every release cut for about a week. The publish failure manifested in two ways. The first was a
recurring @types/vscode version mismatch: Dependabot kept bumping @types/vscode to whatever the latest minor
was, vsce (the marketplace CLI) refused to publish when the @types/vscode version exceeded the
engines.vscode floor, and the next Dependabot cycle would re-bump the types and the next release would re-fail.
I pinned @types/vscode to the floor at v1.4.0 (a fix(vscode): pin @types/vscode to engines.vscode floor commit
buried in the v1.4.0 release notes). That bought me the duration of v1.4.0’s release. The next push would have
re-broken it the moment Dependabot saw the change.
The second failure mode was the bigger problem. Even with the types pinned, the marketplace publish was
silently rejected on the publisher side. The Hidden-Pixel publisher account I’d registered the extension
under was returning zero extensions in the marketplace listing. Not “extension hidden.” Not “extension pending
review.” Zero extensions on the publisher page, full stop, despite multiple successful vsce publish calls in
the publish logs. Microsoft was — and is — silently blocking the listing somewhere between vsce’s “publish
succeeded” response and the marketplace’s eventual rendering.
I don’t know exactly why. The marketplace policy docs are vague. The publisher name is a name I’ve shipped
software under for years. The extension itself was twenty lines of TypeScript and a package.json. There’s a
support form. I filled it out. I got a templated reply saying the extension would be reviewed within five
business days. That was three weeks before v1.4.0.
There was no point continuing to ship a .vsix that the marketplace was going to swallow.
The decision
The decision was harder to make than the work to do. The VS Code extension was a thing I had built, tested, shipped, and made part of the release pipeline. Pulling it felt like quitting. The argument I had to make to myself was that the extension wasn’t doing the work it was supposed to be doing. The work it was supposed to be doing was getting VS Code users into chippy with zero config. It was not, in fact, doing that — the marketplace listing was returning zero extensions. The realistic VS Code user experience was: “search for chippy in extensions, find nothing, give up.” The extension’s presence in the repo wasn’t helping. It was tripping the release pipeline and giving me a false sense of “this is shipping.”
The hidden upside: VS Code can drive any DAP-compliant debug adapter through a plain launch.json configuration.
No extension required. The user writes:
{
"version": "0.2.0",
"configurations": [
{
"type": "chippy",
"request": "launch",
"name": "Debug ROM",
"program": "${workspaceFolder}/program.bin",
"stopOnEntry": true,
"debugServer": 14785
}
]
}
(There’s a small shim required — a debugAdapter definition pointing at the chippy binary, or running chippy
with -dap :14785 separately. The example examples/dap/launch.json shows both.) DAP-integration via plain
launch.json configurations works for VS Code, Cursor, and a growing number of marketplace-adjacent editors.
The bundled extension was a convenience layer. It was not the access point.
So the actual conclusion was: VS Code support hasn’t lost anything. The marketplace listing was never reaching
users anyway. The plain launch.json flow works today, has always worked, and will keep working. I’m removing
the extension package from the repo, removing the vscode-extension job from release.yml, removing the
vscode-ext CI job, removing the extension/vscode-chippy/ directory, and removing the npm dependabot entry
that kept resurrecting the version-mismatch failure.
The git history preserves the extension code if I ever want it back (a git revert of the removal would
resurrect it cleanly). If Microsoft’s marketplace block ever lifts and the publish goes through, it’s a few
hours of work to re-add it. But it’s not load-bearing now and it shouldn’t be in the release pipeline.
The patch-bump rationale
This is a removal, no library API change, no new feature. By SemVer’s rules this is a patch bump (deprecations are MINOR; removals are MAJOR for public API; this is neither — the extension wasn’t part of the 6502 library’s public API). So v1.4.1 is the right number.
Why a patch release the same day as v1.4.0 instead of folding the removal into v1.4.0? Because v1.4.0 had
already been tagged and pushed when I made the decision. The extension was in the v1.4.0 release pipeline; the
publish failed (again); I cut v1.4.1 immediately after as the removal. v1.4.1 is the first chippy release in
about three weeks where the release pipeline went fully green — no failed jobs, no manual intervention, no
vsce retries. That’s worth its own version.
The other reason: I want the v1.4.0 → v1.4.1 transition to be a visible artifact. If anyone in the future asks
“why does the v1.4 release have a .0 with the extension and a .1 without it,” the answer is in the v1.4.1
release notes and the post you’re reading. Folding the removal into v1.4.0 would have hidden it.
The pattern I want to extract
This release is two days’ work for the codebase — the custom-request hook is a four-line interface plus a
six-line dispatch change, the extension removal is a git rm -r plus a few CI/release edits — and it took me
much longer than that elapsed time to decide to do them. The pull-the-extension decision in particular ate
about a week of “is there a way to salvage this” before I admitted there wasn’t.
The lesson I want to carry forward: a CI job that fails every release for an external reason you can’t control is worse than no CI job. It’s worse because it gives you a false sense of “this is shipping” that you have to argue with yourself about every release. It’s worse because it pollutes the release-success signal — a green release means “everything green except the thing that’s always red, which doesn’t count” rather than just “green.” It’s worse because it creates a soft expectation that you’ll eventually fix it, which is a non-trivial emotional cost over weeks of releases.
The right move when something is blocked externally:
- Diagnose: confirm the failure is external, not something you can fix on your side. (For me: the
vsce publishlog saying “published successfully” while the marketplace listing returned zero extensions was the confirmation.) - Quantify the value of the thing: is the feature actually reaching users? In my case, no, because the marketplace was hiding it.
- Check if the value is reachable some other way: in my case, plain
launch.jsonworks in VS Code, Cursor, and any DAP-aware editor. The “users get source-level debugging in VS Code” outcome is reachable without the extension. - If yes, remove the blocked thing. If no, accept the cost or fight the external block.
I picked yes-and-remove. The pipeline is green. The work that v1.5 is about (more on that next week) is unblocked.
The VS Code workflow still works. And the git revert is sitting in the history if I ever need it back.
What the release notes actually say
For v1.4.0:
- DAP
AttachConfig.CustomRequestHandlerextension point; invoked undercpuMufromdispatch’s default arm;handled booldistinguishes “not recognized” from “recognized but errored.” fix(vscode): pin @types/vscode to engines.vscode floor— one last attempt to keep the bundled extension publishing.
For v1.4.1:
- Remove
extension/vscode-chippy/, thevscode-extensionrelease job, thevscode-extCI job, and the npm Dependabot entry. - Patch bump because no library API changed.
- First fully-green release post-breakage.
That’s it. Two small releases. One additive seam, one subtractive cleanup. The two together unblock the v1.5 architectural work I want to write about next.
What v1.5 is going to be about
v1.5 is the release that’s going to take me the longest to write the post for, because it’s the most. The headline: an in-process zero-marshal DAP transport, the TUI’s Registers panel migrated to read through DAP even in local mode, a live-state streaming event, complete CPU ROM coverage (Klaus interrupt, AllSuiteA, Wolfgang Lorenz, the Tom Harte ProcessorTests), and a set of host debug hooks that let nessy build NES-aware debugging without forking the protocol.
The thesis behind v1.5 is the v2.0 architecture: TUI as a DAP client, every panel renders via the protocol, local mode is just a different transport. v1.5 doesn’t ship v2.0 — that’s the explicit non-goal — but it builds every load-bearing piece of v2.0 and proves them on one panel. The Registers panel is the bellwether. The rest of the panels are deferred to v2.0 because the cost of migrating each one is bounded and the cost of getting the v2.0 architecture wrong is unbounded.
Also: the Tom Harte ProcessorTests find a real JSR $20 stack/operand-overlap bug in my CPU that’s been there
since v0.0.1. That’s a fun story.
But that’s all next week. v1.4 is a quiet release that opens the door for it.