From d7a3e75026ef52f6a14f560e0ac20700fd76f0bd Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 11 Oct 2026 17:23:22 +0300 Subject: [PATCH 1/9] fix: the aarch64 Windows build no longer crashes at startup A stripped build for aarch64 Windows crashed at its first thread-local access, before `deed version` printed anything. Any stripped Zig program that touches a threadlocal does the same on that target, and the unstripped build runs. Stripping drops the local symbols, so each thread-local access is relocated against the .tls section with the variable's byte offset in the add that takes the upper bits. The LLD that Zig 0.16 links with reads that offset as a number of 4 KiB pages, so a variable 8 bytes in is looked for 32 KiB in. LLD fixed this in llvm/llvm-project#200060. Until the pinned Zig links with a fixed LLD, build.zig leaves aarch64 Windows unstripped. The debug information goes to a .pdb beside the .exe there, so the binary only grows by the stack trace code, about 250 KB. I also added a CI job that cross-builds the release configuration and the test binary on Linux and runs both on a windows-11-arm runner. The Zig compiler for aarch64 Windows crashes while building deed on that runner, so the tests are built elsewhere, with a new `zig build test-bin` step. Refs #35 --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 1 + ARCHITECTURE.md | 3 +- build.zig | 26 +++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c10759..93bbff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,73 @@ jobs: NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify echo "deed runs on Windows" + # aarch64 Windows is built on Linux and run on an arm runner. The Zig 0.16.0 + # compiler for aarch64 Windows crashes while building deed on that runner, + # so the runner only runs what was built elsewhere. The binary is built the + # way the release builds it, ReleaseSafe with -Dstrip=true, because that is + # the build that used to crash at startup (see build.zig). + build-windows-arm: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Read Zig version + id: zig-version + run: echo "version=$(cat .zigversion)" >> "$GITHUB_OUTPUT" + + - uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig-version.outputs.version }} + + - name: Build deed and its tests for aarch64 Windows + run: | + set -euo pipefail + zig build -Dtarget=aarch64-windows-gnu -Doptimize=ReleaseSafe -Dstrip=true --prefix out + zig build test-bin -Dtarget=aarch64-windows-gnu --prefix out + ls -l out/bin + + - uses: actions/upload-artifact@v4 + with: + name: deed-windows-aarch64 + path: out/bin + if-no-files-found: error + retention-days: 3 + + test-windows-arm: + needs: build-windows-arm + runs-on: windows-11-arm + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + # The checkout is the working directory the tests expect, the same one + # `zig build test` runs them from, and it carries the version to compare. + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: deed-windows-aarch64 + path: out + + - name: Test + run: ./out/deed-test.exe + + # The same check as on x86_64 Windows: a key, an event signed with it, + # and the signature checked through a pipe. + - name: The binary runs + run: | + set -euo pipefail + bin=out/deed.exe + [ -f "$bin" ] || { echo "the build produced no deed.exe" >&2; exit 1; } + got="$("$bin" version)" + want="deed $(sed -n 's/^pub const version = "\(.*\)";$/\1/p' src/main.zig)" + [ "$got" = "$want" ] || { echo "the binary says '$got', expected '$want'" >&2; exit 1; } + sk="$("$bin" key generate --hex)" + NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows arm check" | "$bin" verify + echo "deed runs on aarch64 Windows" + # The Windows installer, end to end against the newest published release, # under both PowerShells: pwsh, and Windows PowerShell 5.1, which is the one # every Windows machine has and the one whose syntax the script keeps to. diff --git a/AGENTS.md b/AGENTS.md index 9c2a4f1..6a8620c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ zig fmt --check . # CI fails on unformatted code zig build -Doptimize=ReleaseSafe -Dstrip=true # the release build python3 bench/run.py zig-out/bin/deed # benchmarks, see BENCHMARKS.md zig build test -Dtarget=x86_64-windows-gnu # compile the tests for Windows; the run step then fails, the host cannot run them +zig build test-bin -Dtarget=aarch64-windows-gnu # build the tests into zig-out/bin/deed-test.exe to run on another machine ``` Use the Zig version in `.zigversion`. Building only the executable for Windows is not enough to catch a break there: code the executable never reaches, the test relay for one, only gets analysed when the test binary is built, which is what the Windows line above does. The `nostr` dependency is pinned by URL and hash in `build.zig.zon`; move it with `zig fetch --save=nostr ` and check the diff is only the url and hash lines. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7fb30ef..51f8782 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,6 +185,7 @@ macOS, Linux and Windows, on x86_64 and aarch64. Linux release builds are static - Windows needs an allocator to split the command line into arguments, so `main` uses `Args.Iterator.initAllocator`, which is free elsewhere. - A Windows console decodes text with its own code page. `console.zig` switches it to UTF-8 for the length of the run, the output page only when stdout is the console and the input page only when stdin is, so the deeds in a pipeline do not switch it back under each other. It puts the pages back before exit and on Ctrl-C. It does nothing on other systems. - The test relay shrinks a socket's receive window. On Windows std's sockets are AFD handles Winsock does not know, so it sends the option as the same AFD request std uses for its own socket options, and that is best effort. +- On aarch64 Windows `build.zig` ignores `-Dstrip`. With Zig 0.16 a stripped build for that target crashes at its first thread-local access, because the linker misreads the offset in the relocation for the upper bits of a thread-local address (llvm/llvm-project#200060). The debug information sits in a `.pdb` beside the `.exe` there, so the binary only carries the stack trace code it would otherwise drop. Building only the executable for a target does not analyse code the executable never reaches. `zig build test -Dtarget=x86_64-windows-gnu` builds the test binary too, which is the check that finds a break in the test relay. The host cannot run the result, so the command ends with an error saying so once the compile has succeeded. @@ -198,7 +199,7 @@ Two details matter when adding a test that dials. Use `testrelay.DialAllocator` `bench/` is not a test suite. `bench/run.py` measures size, startup, memory, signing and verifying throughput, store speed, and relays over loopback against a build, using `bench/relay.py` as the relay. [`BENCHMARKS.md`](BENCHMARKS.md) has the numbers and how to reproduce them. -CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. +CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, builds the aarch64 Windows binary and test binary on Linux and runs both on a Windows ARM runner (the Zig compiler for that target crashes there), checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. ## Where to start reading diff --git a/build.zig b/build.zig index 0c39b82..7b3564a 100644 --- a/build.zig +++ b/build.zig @@ -13,7 +13,23 @@ pub fn build(b: *std.Build) void { // Release builds pass -Dstrip. Without it a Linux binary carries about // nine megabytes of debug sections, three quarters of the file. - const strip = b.option(bool, "strip", "Leave debug information out of the binary"); + const strip_wanted = b.option(bool, "strip", "Leave debug information out of the binary"); + + // Except on aarch64 Windows, where a stripped build crashes at its first + // thread-local access. Stripping drops the local symbols, so the compiler + // addresses each thread-local variable as an offset into the .tls section, + // and puts that offset in the instruction that relocates the upper bits. + // The linker in Zig 0.16 (LLD from LLVM 21) reads that offset as a count of + // 4 KiB pages rather than bytes, so a variable 8 bytes in is looked for + // 32 KiB in. LLD fixed this in llvm/llvm-project#200060, and the exception + // can go once the pinned Zig links with an LLD that has the fix. Little is + // lost meanwhile: a Windows binary keeps its debug information in a .pdb + // beside it, which the release does not ship, so the .exe only grows by + // the stack trace code, about 250 KB. + const strip = if (target.result.os.tag == .windows and target.result.cpu.arch == .aarch64) + false + else + strip_wanted; const exe_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -40,10 +56,18 @@ pub fn build(b: *std.Build) void { // contain the text. const test_filter = b.option([]const u8, "test-filter", "Run only the tests whose name contains this text"); const exe_tests = b.addTest(.{ + .name = "deed-test", .root_module = exe_mod, .filters = if (test_filter) |f| b.dupeStrings(&.{f}) else &.{}, }); const run_exe_tests = b.addRunArtifact(exe_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_exe_tests.step); + + // `zig build test-bin -Dtarget=` builds the tests into + // zig-out/bin/deed-test without running them, for a machine that can run + // that target but not build it. CI uses it for aarch64 Windows. + const install_tests = b.addInstallArtifact(exe_tests, .{}); + const test_bin_step = b.step("test-bin", "Build the unit tests without running them"); + test_bin_step.dependOn(&install_tests.step); } From b393c737ab2485fa711c2613f03b2e280fe622a9 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 11 Oct 2026 17:23:35 +0300 Subject: [PATCH 2/9] feat: ship the aarch64 Windows build and install it on ARM64 Releases now carry deed--windows-aarch64.zip with its .sha256, built from the tag like the others and run on a windows-11-arm runner before the draft is lifted. The publish check expects twelve assets. install.ps1 picks the ARM64 build on an ARM64 machine. Older releases have no ARM64 zip, so on a 404 for it the installer falls back to the x86_64 build, which Windows on ARM runs under emulation. Any other download failure is still reported as one. CI runs the installer checks on windows-11-arm as well as windows-latest. Closes #35 --- .github/RELEASE_NOTES.md | 6 +++++- .github/workflows/ci.yml | 5 ++++- .github/workflows/release.yml | 14 +++++++++----- ARCHITECTURE.md | 2 +- README.md | 2 +- scripts/install.ps1 | 28 ++++++++++++++++++++-------- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index c970351..4467a3d 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,7 +1,11 @@ -**deed** is a command line for nostr, built in Zig. It runs on macOS and Linux, on both Intel and ARM, and on Windows. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run. The Windows builds are zips holding `deed.exe`, which is not code-signed. +**deed** is a command line for nostr, built in Zig. It runs on macOS, Linux and Windows, on both Intel and ARM. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run. The Windows builds are zips holding `deed.exe`, which is not code-signed. Every artifact below is published with a `.sha256` beside it, so the download can be checked against a digest that was written by the same job that built it. +### Unreleased + +**A native build for Windows on ARM.** Releases now carry a zip for aarch64 Windows, run on a Windows ARM machine before the release is published, and the PowerShell installer puts it in place on an ARM64 machine. It used to crash at startup: a stripped build for that target looked for every thread-local variable in the wrong place, because of a linker bug fixed upstream in llvm/llvm-project#200060. That target is now built without stripping, which costs about 250 KB, since its debug information goes to a separate `.pdb` that is not shipped. For an older release without the ARM64 zip, the installer still installs the x86_64 build, which Windows on ARM runs under emulation. + ### What's new in v0.5.0 deed now covers most of what a nostr user does from a shell. Every verb reads records as lines of JSON and writes one result per line, so they pipe into each other. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93bbff8..befe33f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,12 +240,15 @@ jobs: # The Windows installer, end to end against the newest published release, # under both PowerShells: pwsh, and Windows PowerShell 5.1, which is the one # every Windows machine has and the one whose syntax the script keeps to. + # On the arm runner the installer picks the ARM64 build, or the x86_64 one + # when the release has no ARM64 build. install-windows: strategy: fail-fast: false matrix: + runner: [windows-latest, windows-11-arm] shell: [pwsh, powershell] - runs-on: windows-latest + runs-on: ${{ matrix.runner }} timeout-minutes: 15 defaults: run: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98b7312..83f0df0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,13 +82,14 @@ jobs: v="${{ steps.version.outputs.version }}" mkdir -p dist - for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu; do + for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu aarch64-windows-gnu; do case "$target" in aarch64-macos-none) name=macos-aarch64 ;; x86_64-macos-none) name=macos-x86_64 ;; x86_64-linux-musl) name=linux-x86_64 ;; aarch64-linux-musl) name=linux-aarch64 ;; x86_64-windows-gnu) name=windows-x86_64 ;; + aarch64-windows-gnu) name=windows-aarch64 ;; esac rm -rf "out-$target" @@ -225,6 +226,8 @@ jobs: include: - runner: windows-latest asset: windows-x86_64 + - runner: windows-11-arm + asset: windows-aarch64 runs-on: ${{ matrix.runner }} defaults: run: @@ -289,10 +292,11 @@ jobs: "deed-$v-linux-x86_64.tar.gz" "deed-$v-linux-x86_64.tar.gz.sha256" \ "deed-$v-macos-aarch64.tar.gz" "deed-$v-macos-aarch64.tar.gz.sha256" \ "deed-$v-macos-x86_64.tar.gz" "deed-$v-macos-x86_64.tar.gz.sha256" \ + "deed-$v-windows-aarch64.zip" "deed-$v-windows-aarch64.zip.sha256" \ "deed-$v-windows-x86_64.zip" "deed-$v-windows-x86_64.zip.sha256" | sort)" actual="$(ls dist | sort)" diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") \ - || { echo "the release assets are not the ten expected files" >&2; exit 1; } + || { echo "the release assets are not the twelve expected files" >&2; exit 1; } for f in dist/*; do [ -s "$f" ] || { echo "$f is empty" >&2; exit 1; } @@ -305,9 +309,9 @@ jobs: done # A glob that matched nothing would leave the loop body unrun and the # step green, having verified precisely nothing. - [ "$checked" = "5" ] || { echo "verified $checked checksums, expected 5" >&2; exit 1; } + [ "$checked" = "6" ] || { echo "verified $checked checksums, expected 6" >&2; exit 1; } - echo "ten assets, all present, none empty, every digest matching" + echo "twelve assets, all present, none empty, every digest matching" - name: Lift the draft, then read the page back env: @@ -323,6 +327,6 @@ jobs: # Read the list again from the published release. What was checked in # the step above was the draft, and this is the page a reader opens. count="$(gh release view "$GITHUB_REF_NAME" --json assets --jq '.assets | length')" - [ "$count" = "10" ] || { echo "the published release carries $count assets, expected 10" >&2; exit 1; } + [ "$count" = "12" ] || { echo "the published release carries $count assets, expected 12" >&2; exit 1; } echo "published $GITHUB_REF_NAME" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 51f8782..c25bbe7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -199,7 +199,7 @@ Two details matter when adding a test that dials. Use `testrelay.DialAllocator` `bench/` is not a test suite. `bench/run.py` measures size, startup, memory, signing and verifying throughput, store speed, and relays over loopback against a build, using `bench/relay.py` as the relay. [`BENCHMARKS.md`](BENCHMARKS.md) has the numbers and how to reproduce them. -CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, builds the aarch64 Windows binary and test binary on Linux and runs both on a Windows ARM runner (the Zig compiler for that target crashes there), checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. +CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, builds the aarch64 Windows binary and test binary on Linux and runs both on a Windows ARM runner (the Zig compiler for that target crashes there), checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on x86_64 and ARM Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. ## Where to start reading diff --git a/README.md b/README.md index 13337f4..467563f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Each checks the download against the SHA-256 published beside it, and if the dig `--version ` installs a particular release, `--prefix ` puts it somewhere else, and `--archive ` installs from an archive you already have, which still wants its `.sha256` beside it. `--help` lists them. On Windows they are `-Version`, `-Prefix`, `-Archive` and `-Help` when you run the script as a file, and `$env:DEED_VERSION`, `$env:DEED_PREFIX` and `$env:DEED_ARCHIVE` for the piped line. -On Windows on ARM the installer puts the x86_64 build in place, which Windows 11 runs under emulation. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. +On Windows on ARM the installer puts the native ARM64 build in place, or the x86_64 build, which Windows 11 runs under emulation, for an older release that has no ARM64 build. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. ## What it does diff --git a/scripts/install.ps1 b/scripts/install.ps1 index bff1a56..f9f7226 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -108,11 +108,8 @@ Nothing needs administrator rights. return 0 } - # Which build this machine wants. - # - # There is no ARM build for Windows. Windows 11 on ARM runs x86_64 programs - # under its own emulation, and the x86_64 build is the one every release is - # checked on, so ARM64 machines get that. + # Which build this machine wants. An ARM64 machine gets the ARM64 build + # when the release has one; see Install-Deed for the releases that do not. function Get-Platform { if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Fail 'this script installs the Windows build. On macOS and Linux, run: curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash' @@ -123,8 +120,8 @@ Nothing needs administrator rights. if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } switch ($arch) { 'AMD64' { return 'windows-x86_64' } - 'ARM64' { return 'windows-x86_64' } - default { Fail "no deed build for '$arch'. The published Windows build is x86_64, which also runs on ARM64." } + 'ARM64' { return 'windows-aarch64' } + default { Fail "no deed build for '$arch'. The published Windows builds are x86_64 and ARM64." } } } @@ -271,7 +268,22 @@ Nothing needs administrator rights. try { Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing } catch { - Fail "download failed. There may be no $platform build for $tag." + # Older releases carry no ARM64 build. Windows on ARM runs the + # x86_64 one under emulation, so a release without the ARM64 + # zip still installs. Only a 404 means that: any other failure + # is reported, not papered over with a different build. + if ($platform -ne 'windows-aarch64' -or (Get-Status $_) -ne 404) { + Fail "download failed. There may be no $platform build for $tag." + } + $platform = 'windows-x86_64' + $name = "deed-$($tag.Substring(1))-$platform.zip" + $url = "https://github.com/$repo/releases/download/$tag/$name" + Say "$tag has no ARM64 build. Downloading $name, which Windows on ARM runs under emulation..." + try { + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + } catch { + Fail "download failed. There may be no $platform build for $tag." + } } # Required, not best effort. A verification step that any transient From 11b38582e10c7980ec8d04e3634af9cc9e91b177 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 11 Oct 2026 17:41:01 +0300 Subject: [PATCH 3/9] test: two network tests hold on Windows on ARM On a windows-11-arm runner two tests failed that pass on x86_64 Windows. The deaf-relay write test needs a write that blocks, and Windows completes a large loopback send without the peer reading, so the write finished and no timeout came. The nostr library skips its own tests of this kind on Windows for the same reason. I skip this one there too, and added a test that covers the deadline in writeBy with a write that waits until cancelled, so it no longer depends on a socket filling up. The HTTP test server answered without reading the request body and then closed. Closing a socket with unread bytes resets the connection, and Windows then drops the part of the response the client had not read yet, so "a POST sends its body" failed now and then. The server now reads the body to its end before it answers. --- src/http.zig | 16 ++++++++++++++++ src/negclient.zig | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/http.zig b/src/http.zig index d83d8fd..566f350 100644 --- a/src/http.zig +++ b/src/http.zig @@ -995,6 +995,7 @@ pub const TestServer = struct { while (it.next()) |h| { if (std.ascii.eqlIgnoreCase(h.name, "authorization")) has_auth = true; } + try drainBody(&req); var extra: [1]Header = undefined; var n: usize = 0; @@ -1014,8 +1015,23 @@ pub const TestServer = struct { try req.respond(content, .{ .status = route.status, .keep_alive = false, .extra_headers = extra[0..n] }); return; } + try drainBody(&req); try req.respond("not found", .{ .status = .not_found, .keep_alive = false }); } + + /// Reads the request body to its end before the answer goes out. A socket + /// closed with bytes it never read resets the connection, and Windows + /// then drops whatever of the response the client had not read yet. This + /// invalidates the strings in the request head. + fn drainBody(req: *http.Server.Request) !void { + // Only a body with a known end. Without one std hands back either its + // shared `Reader.ending`, which is read-only memory that a discard + // writes to, or the raw stream, which ends only when the client + // closes, and the client is waiting for the answer. + if (req.head.content_length == null and req.head.transfer_encoding != .chunked) return; + var buf: [1024]u8 = undefined; + _ = try req.readerExpectNone(&buf).discardRemaining(); + } }; /// Not `std.testing.allocator`: a request that times out is cancelled, and diff --git a/src/negclient.zig b/src/negclient.zig index 08eab62..886735c 100644 --- a/src/negclient.zig +++ b/src/negclient.zig @@ -21,6 +21,7 @@ //! over, because a relay can send anything. const std = @import("std"); +const builtin = @import("builtin"); const nostr = @import("nostr"); const Io = std.Io; @@ -927,7 +928,25 @@ test "a reason a relay wrote cannot carry terminal controls" { try testing.expectEqualStrings("NOTICE: a?[2Jb?", n.text()); } +test "a write that never finishes gives up at the deadline" { + const io = testing.io; + // Stands in for a write into a full socket: it waits until cancelled. + const Stuck = struct { + fn write(i: Io) anyerror!void { + try i.sleep(.fromSeconds(60), .awake); + } + }; + const started = Io.Timestamp.now(io, .awake).toMilliseconds(); + try testing.expectError(error.Timeout, writeBy(io, deadlineIn(io, 300), Stuck.write, .{io})); + const took = Io.Timestamp.now(io, .awake).toMilliseconds() - started; + try testing.expect(took < 3_000); +} + test "a write to a relay that stops reading gives up at the deadline" { + // Windows completes a large send on a loopback socket without waiting for + // the peer to read, so the write cannot block there. The deadline is + // covered everywhere by the test above, which uses no socket. + if (builtin.os.tag == .windows) return error.SkipZigTest; const io = testing.io; const gpa = std.heap.page_allocator; var deaf: testrelay.Relay = undefined; From fa00859f90a31157f360843fa8644e3395436c7d Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:05:03 +0300 Subject: [PATCH 4/9] test: the deaf-relay write test holds on every platform The test skipped on all Windows but only failed on Windows on ARM, where one 8 MB send completes without the peer reading and so returned before the deadline. The write now repeats the event, bounded at 256 sends, so it reaches a send that waits for room on any platform where a socket fills, and still ends where one never does. The skip is gone. --- src/negclient.zig | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/negclient.zig b/src/negclient.zig index 886735c..8317e1f 100644 --- a/src/negclient.zig +++ b/src/negclient.zig @@ -21,7 +21,6 @@ //! over, because a relay can send anything. const std = @import("std"); -const builtin = @import("builtin"); const nostr = @import("nostr"); const Io = std.Io; @@ -943,10 +942,6 @@ test "a write that never finishes gives up at the deadline" { } test "a write to a relay that stops reading gives up at the deadline" { - // Windows completes a large send on a loopback socket without waiting for - // the peer to read, so the write cannot block there. The deadline is - // covered everywhere by the test above, which uses no socket. - if (builtin.os.tag == .windows) return error.SkipZigTest; const io = testing.io; const gpa = std.heap.page_allocator; var deaf: testrelay.Relay = undefined; @@ -967,8 +962,12 @@ test "a write to a relay that stops reading gives up at the deadline" { defer arena.deinit(); const ev = try nostr.event.create(arena.allocator(), signer, kp, 1_700_000_000, 1, &.{}, content, null); const Publish = struct { + // Windows takes a large send on a loopback socket without waiting for + // the peer to read, so one event may not block there. Writing on, with + // a bound so a socket that never fills still ends, reaches a write + // that waits for room that does not come. fn now(rr: *relay.Relay, e: Event) anyerror!void { - return rr.publish(e); + for (0..256) |_| try rr.publish(e); } }; const started = Io.Timestamp.now(io, .awake).toMilliseconds(); From de0e0107ae811db0218e54f7fc9ea95d2aa7cf79 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:05:08 +0300 Subject: [PATCH 5/9] fix: install.ps1 picks ARM64 from an emulated PowerShell An x64 PowerShell on a Windows on ARM machine reports AMD64 in its environment, so the installer chose the x86_64 build there. It now reads the machine's own architecture from the registry first and keeps the environment variables as the fallback. The message after a failed fallback named windows-x86_64 even when the release had no Windows build at all. It now says the tag may not exist or has no Windows build. --- scripts/install.ps1 | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f9f7226..1c95f0f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -114,9 +114,19 @@ Nothing needs administrator rights. if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Fail 'this script installs the Windows build. On macOS and Linux, run: curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash' } - # A 32-bit PowerShell on a 64-bit Windows reports x86 here and the real - # architecture in PROCESSOR_ARCHITEW6432. - $arch = $env:PROCESSOR_ARCHITEW6432 + # The machine's own architecture, not the process's. An x64 PowerShell + # running under emulation on a Windows on ARM machine reports AMD64 in + # its environment, so the value Windows keeps in the registry is read + # first. The environment is the fallback: a 32-bit PowerShell on a + # 64-bit Windows reports x86 there and the real architecture in + # PROCESSOR_ARCHITEW6432. + $arch = $null + try { + $arch = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE -ErrorAction Stop).PROCESSOR_ARCHITECTURE + } catch { + $arch = $null + } + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITEW6432 } if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } switch ($arch) { 'AMD64' { return 'windows-x86_64' } @@ -273,7 +283,7 @@ Nothing needs administrator rights. # zip still installs. Only a 404 means that: any other failure # is reported, not papered over with a different build. if ($platform -ne 'windows-aarch64' -or (Get-Status $_) -ne 404) { - Fail "download failed. There may be no $platform build for $tag." + Fail "download failed. $tag may not exist, or has no Windows build." } $platform = 'windows-x86_64' $name = "deed-$($tag.Substring(1))-$platform.zip" @@ -282,7 +292,7 @@ Nothing needs administrator rights. try { Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing } catch { - Fail "download failed. There may be no $platform build for $tag." + Fail "download failed. $tag may not exist, or has no Windows build." } } From f915bf19b41878e436614dc09798a7c2e07aeaef Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:05:08 +0300 Subject: [PATCH 6/9] ci: check which build the Windows installer put in place After the install on each Windows runner, read the machine field of the installed deed.exe. It must be ARM64 on the arm runner when the release carries an ARM64 zip, and x86_64 otherwise. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index befe33f..c38b61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,13 @@ jobs: exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" + # Whether that release carries an ARM64 build, which decides what the + # installer on the arm runner must have put in place. + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | grep -q -- '-windows-aarch64\.zip$'; then + echo "has_arm64=true" >> "$GITHUB_OUTPUT" + else + echo "has_arm64=false" >> "$GITHUB_OUTPUT" + fi - name: The installer parses run: | @@ -321,6 +328,23 @@ jobs: "bin=$(Join-Path $prefix 'deed.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii Write-Host "installed $got, on PATH once" + # Which build the installer chose, read from the file and not from what + # runs: the machine field of the PE header, a u16 four bytes past the + # offset stored at 0x3C. The arm runner must get the ARM64 build when the + # release has one, and x86_64 (run under emulation) when it does not. + - name: The installed deed.exe is the build for this machine + env: + RUNNER: ${{ matrix.runner }} + HAS_ARM64: ${{ steps.release.outputs.has_arm64 }} + run: | + $bytes = [IO.File]::ReadAllBytes($env:bin) + $pe = [BitConverter]::ToInt32($bytes, 0x3C) + $machine = [BitConverter]::ToUInt16($bytes, $pe + 4) + $want = 0x8664 + if ($env:RUNNER -eq 'windows-11-arm' -and $env:HAS_ARM64 -eq 'true') { $want = 0xAA64 } + if ($machine -ne $want) { Write-Host ("PE machine is 0x{0:X4}, expected 0x{1:X4}" -f $machine, $want); exit 1 } + Write-Host ("PE machine is 0x{0:X4}" -f $machine) + # A real gesture rather than just `version`: libsecp256k1, the bech32 # encoder and the JSON writer and reader in one line, as the check for # install.sh does. In bash, so the pipe between the two deeds carries From 84ffac6247c875ad99030d43196116ae6483df21 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:08:38 +0300 Subject: [PATCH 7/9] chore: build on nostr 0.16.0 The 0.16.0 error sets of the keys, NIP-44, NIP-04, NIP-59 and NIP-46 code gain Canceled, which is returned when a cancel lands while waiting for randomness. deed's own draws of randomness (the NIP-04 iv, the NIP-44 nonce, the websocket mask of a COUNT frame, the gift wrap timestamp, a new mnemonic) turned every error into a random-number failure. A cancel now stays a cancel. --- build.zig.zon | 4 ++-- src/cmd_count.zig | 2 +- src/cmd_crypt.zig | 4 ++-- src/cmd_gift.zig | 2 +- src/cmd_key.zig | 3 ++- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 4b37d6c..af2bf19 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,8 +10,8 @@ // themselves later, and "whatever main happened to be that afternoon" // is not that. .nostr = .{ - .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.15.1.tar.gz", - .hash = "nostr-0.15.1-CMyPzUp8EQA8ebwjsaco8u35hSiprJW0DS74viHXIVvr", + .url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.16.0.tar.gz", + .hash = "nostr-0.16.0-CMyPzYmkEQBqRKpV_8jJYu769GBnHVzyZELHxbblGk0V", }, }, .paths = .{ diff --git a/src/cmd_count.zig b/src/cmd_count.zig index d76fc4e..210aa21 100644 --- a/src/cmd_count.zig +++ b/src/cmd_count.zig @@ -202,7 +202,7 @@ fn sendText(gpa: std.mem.Allocator, io: Io, r: *relay.Relay, text: []const u8) ! var frame: std.ArrayList(u8) = .empty; defer frame.deinit(gpa); var mask: [4]u8 = undefined; - io.randomSecure(&mask) catch return error.RandomFailed; + io.randomSecure(&mask) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; try nostr.websocket.appendClientFrame(&frame, gpa, .text, text, mask); r.conn.write_lock.lockUncancelable(io); defer r.conn.write_lock.unlock(io); diff --git a/src/cmd_crypt.zig b/src/cmd_crypt.zig index 167bf94..6aea27a 100644 --- a/src/cmd_crypt.zig +++ b/src/cmd_crypt.zig @@ -237,11 +237,11 @@ fn cryptAll( .encrypt => blk: { if (legacy) { var iv: [16]u8 = undefined; - io.randomSecure(&iv) catch return error.RandomFailed; + io.randomSecure(&iv) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; break :blk nip04.encryptWithIv(gpa, conversation_key, iv, text); } var nonce: [32]u8 = undefined; - io.randomSecure(&nonce) catch return error.RandomFailed; + io.randomSecure(&nonce) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; break :blk nip44.encryptWithConversationKey(gpa, conversation_key, text, nonce); }, .decrypt => if (legacy) diff --git a/src/cmd_gift.zig b/src/cmd_gift.zig index fcef67b..d4f1c66 100644 --- a/src/cmd_gift.zig +++ b/src/cmd_gift.zig @@ -362,7 +362,7 @@ fn seal(a: std.mem.Allocator, io: std.Io, signer: *signing.Signer, to: keys.Publ /// Up to two days before now, never now itself, as NIP-59 asks of a seal. fn randomPastTimestamp(io: std.Io) !i64 { var buf: [8]u8 = undefined; - io.randomSecure(&buf) catch return error.RandomFailed; + io.randomSecure(&buf) catch |e| return if (e == error.Canceled) error.Canceled else error.RandomFailed; const r = std.mem.readInt(u64, &buf, .little); const two_days: u64 = 2 * 24 * 60 * 60; const offset: i64 = 1 + @as(i64, @intCast(r % two_days)); diff --git a/src/cmd_key.zig b/src/cmd_key.zig index 6839a3b..7cc07b0 100644 --- a/src/cmd_key.zig +++ b/src/cmd_key.zig @@ -541,7 +541,8 @@ fn mnemonic( defer std.crypto.secureZero(u8, &entropy); // 12 words carry 128 bits, and every 3 more words carry 32 more. const n = (words / 3) * 4; - io.randomSecure(entropy[0..n]) catch { + io.randomSecure(entropy[0..n]) catch |e| { + if (e == error.Canceled) return e; try err.writeAll("deed key mnemonic: could not draw randomness from the system\n"); return cli.exit_fail; }; From ca76c88a76f62caef08b22c80a14597ade0690da Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:08:38 +0300 Subject: [PATCH 8/9] chore(release): 0.5.1 Version in build.zig.zon and src/main.zig, the installer's examples, and the release notes for 0.5.1. --- .github/RELEASE_NOTES.md | 4 +++- build.zig.zon | 2 +- scripts/install.ps1 | 6 +++--- src/main.zig | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index 4467a3d..dcc80a1 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -2,10 +2,12 @@ Every artifact below is published with a `.sha256` beside it, so the download can be checked against a digest that was written by the same job that built it. -### Unreleased +### What's new in v0.5.1 **A native build for Windows on ARM.** Releases now carry a zip for aarch64 Windows, run on a Windows ARM machine before the release is published, and the PowerShell installer puts it in place on an ARM64 machine. It used to crash at startup: a stripped build for that target looked for every thread-local variable in the wrong place, because of a linker bug fixed upstream in llvm/llvm-project#200060. That target is now built without stripping, which costs about 250 KB, since its debug information goes to a separate `.pdb` that is not shipped. For an older release without the ARM64 zip, the installer still installs the x86_64 build, which Windows on ARM runs under emulation. +deed is built on nostr 0.16.0. A command that is cancelled while it waits for randomness now reports the cancel, where it used to report a random-number failure. + ### What's new in v0.5.0 deed now covers most of what a nostr user does from a shell. Every verb reads records as lines of JSON and writes one result per line, so they pipe into each other. diff --git a/build.zig.zon b/build.zig.zon index af2bf19..d7dce91 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .deed, - .version = "0.5.0", + .version = "0.5.1", .fingerprint = 0x89498c2094a1a4a3, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1c95f0f..0a60bc8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -11,17 +11,17 @@ # # Options, when the script runs as a file: # -# .\install.ps1 -Version v0.5.0 -Prefix C:\tools\deed +# .\install.ps1 -Version v0.5.1 -Prefix C:\tools\deed # # A piped run has no command line to put them on, so it reads the same options # from the environment: # -# $env:DEED_VERSION = 'v0.5.0' +# $env:DEED_VERSION = 'v0.5.1' # irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex # # or takes them when the download is run as a script block: # -# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1))) -Version v0.5.0 +# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1))) -Version v0.5.1 # # An option given on the command line wins over the environment. # diff --git a/src/main.zig b/src/main.zig index 0113950..0fc5614 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,7 +8,7 @@ const cli = @import("cli.zig"); const console = @import("console.zig"); const verbs = @import("verbs.zig"); -pub const version = "0.5.0"; +pub const version = "0.5.1"; pub fn main(init: std.process.Init) !void { // A general-purpose allocator, not `page_allocator`: that one maps pages From 72f363195500e6272f04f6ddd5a4eba19dd7c7ec Mon Sep 17 00:00:00 2001 From: "user.email" Date: Sun, 11 Oct 2026 18:11:30 +0300 Subject: [PATCH 9/9] docs: describe deed as the fastest and smallest nostr command line The README's tagline now says what the benchmarks show: deed starts faster and ships smaller than the other nostr command lines that talk to relays. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 467563f..1da24f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # deed -**The nostr command line.** +**Fastest and smallest nostr command line, built for AI agents and humans.** A deed is two things at once: a signed instrument, and a thing done. So is a nostr event. `deed` makes them, reads them and proves them.