Skip to content

Bump the exporter to 1.2.1, and build the Windows binary before releasing it - #110

Merged
parawanderer merged 9 commits into
mainfrom
release/exporter-1.2.1
Aug 19, 2026
Merged

Bump the exporter to 1.2.1, and build the Windows binary before releasing it#110
parawanderer merged 9 commits into
mainfrom
release/exporter-1.2.1

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

Cuts 1.2.1, which contains the fix for the crash in #89 that 1.2.0 misses by 100 minutes — and adds the CI that would have caught a second, unrelated Windows failure before it shipped.

exporter-v1.2.0 published 2026-08-16 12:29 UTC
DER fix merged (#103) 2026-08-16 14:08 UTC

So the current Latest release is the one that cannot get past it.

Why the release is urgent rather than tidy

A second user has now hit the #89 crash, from a downloaded build rather than a clone, and their error is byte-for-byte identical to the first: element claims 109 bytes, 61 remain, on a different account and a different OS.

That kills the reading this had when only one report existed. It is not one unlucky item in sixty-one: a structure of exactly that shape sits in the ProtectedCloudStorage view of unrelated accounts, and everyone who has one loses their whole export — before a single tag is read, since the failure is inside unlock.

Nothing new ships for that. The fix has been on main since the 16th and is re-verified here against the pin main now carries, which has moved since (102dd8e).

And a second, unrelated Windows failure

A Windows user on 1.2.0 reported OpenTagViewer.exe parou de funcionar — the process killed by Windows, no traceback, nothing in any log, because a native fault does not raise. Two gaps let that reach them:

Nothing ever built the Windows binary before a release. macos-exporter-python.yml fires on release: published and nothing else, so the first run of any .exe was by whoever downloaded it. exporter-build-check.yml now builds it on PRs touching python/ and starts it. It uploads nothing and is not part of releasing — its job is to fail a PR instead of a download.

And --self-test proved files were present without running any code. It checked the pinned roots and anisette's certificate; both would have passed here. unicorn loads its native library through ctypes, so --collect-all unicorn puts the file in the bundle and nothing established that it loads or executes — exactly the shape of a hard crash with no Python error to catch.

It now emulates four bytes of ARM64 and reads the register back:

pinned roots: 4 (101, 102, 103, 500)
anisette root: present
cpu emulator: ran ARM64 and read back 42
self-test passed

Why both are in one PR

The self-test runs in the release workflow's own smoke step, before any asset is uploaded. Shipping it with 1.2.1 means the 1.2.1 binaries are themselves checked by it — on all four platforms — rather than the check arriving one release too late to cover the release that motivated it.

What is not claimed

The Windows crash is not fixed here, and the emulator check is a hypothesis rather than a diagnosis. A native fault on Windows cannot be reproduced from the machine this was written on. What this changes is that the next one is a red build instead of a photograph.

To diagnose the reported one, the file to ask for is %TEMP%\OpenTagViewer\exporter.log — the wizard logs there on Windows and flushes per record, so everything up to the fault survives even though the fault itself will not appear.

Verified

service_keys_from_der(bytes([0x30, 0x03, 0x02, 0x7F, 0x41]))
-> ServiceKeyError    (the #89 fix is present at the pin main carries)

441 tests pass, flake8 clean, release_version.py --kind exporter --tag exporter-v1.2.1 returns 1.2.1. The new workflow's YAML parses and its PyInstaller invocation is byte-identical to the release workflow's Windows step.

Relates to #89.


PR description summarised by Claude Code.

1.2.0 went out at 12:29 UTC on the 16th and the fix for the crash in #89 merged at 14:08, so
the current release is the one that cannot get past it - and a second user has now hit it,
from a downloaded build rather than a clone.

Their error is byte-for-byte identical to the first: "element claims 109 bytes, 61 remain", on
a different account and a different operating system. That rules out the reading this had when
only one report existed. It is not one unlucky item in sixty-one; a structure of exactly that
shape sits in the ProtectedCloudStorage view of unrelated accounts, and every user who has one
loses the whole export to it.

Nothing new is being shipped - the fix has been on main since the 16th and is verified again
here against the pin main now carries, which has moved since. Three changes reach the exporter
since 1.2.0: that fix, one FindMy.py pin for the whole repository, and a secret file being
usable on Windows.

441 tests, flake8 clean, and `release_version.py --kind exporter --tag exporter-v1.2.1` passes.
… native code

A Windows user on 1.2.0 got `OpenTagViewer.exe parou de funcionar` - the process killed by
Windows, no traceback, nothing in any log, because a native fault does not raise. Two gaps
let that reach them.

**Nothing ever built the Windows binary before a release.** The exporter workflow fires on
`release: published` and nothing else, so the first run of any .exe was by whoever downloaded
it. `exporter-build-check.yml` builds it on pull requests touching python/ and starts it. It
uploads nothing and is not part of releasing; its whole job is to fail a PR instead of a
download. Windows only for now, because it is the platform that had no pre-release coverage
and the one that broke - the steps are otherwise platform-agnostic and the matrix is two lines
to widen.

**And the self-test proved files were present without running any code.** It checked the
pinned roots and anisette's certificate, both of which would have passed here. unicorn loads
its native library through ctypes, so `--collect-all unicorn` puts the file in the bundle and
nothing establishes that it loads or executes - which is exactly the shape of a hard crash
with no Python error to catch.

So it now emulates four bytes of ARM64 and reads the register back. Local, instant, no network
and no account, and it fails a build rather than a machine. Anisette needs that path to work,
since emulating Apple's ADI library is how a sign-in happens without a third-party server.

Not a fix: what crashed on that user's machine is still unknown, and the emulator is a
hypothesis rather than a diagnosis. This is what turns the next one into a red build.
The first run of this workflow failed with "Failed to spawn: pyinstaller / program not found".
`uv sync --frozen` installs the default groups, and PyInstaller is in `build` - so the check
built nothing and would have gone green the moment anybody made it non-blocking.

Taken from the release workflow's install step verbatim, for the same reason its PyInstaller
invocation is: a build check that installs a different set is checking a different bundle.
The first Windows run produced no output at all and exit 127. Every line was still in the
buffer when the process was killed: stdout is block-buffered the moment it is a pipe rather
than a terminal, which it always is in CI, and a native fault never flushes.

That is the difference between "the self-test failed" and "it failed at the emulator", and
only the second is worth having. `--version` looked fine for the same reason it always did -
it exits normally, so its buffer is flushed on the way out.
The Windows build dies inside the emulator check with no exception - the try/except printed
nothing, so there is nothing to catch and nothing in a traceback. The only evidence a hard
fault leaves is the last line that got out before it.

So each stage announces itself before it runs: import, construct, mem_map, mem_write,
emu_start. Whichever has no answer after it is the one that killed the process. The import
step also prints where unicorn was loaded from, because a bundle carrying the Python package
without the native library beside it is the likeliest cause and looks identical from outside.

This is instrumentation, not a fix. It buys one CI round trip and a line number.
…taller

The frozen binary gets through import, construct, mem_map and mem_write and dies inside
emu_start - so the native library loads and the bundle is not the problem. That leaves two
possibilities that look the same from outside: freezing breaks execution, or executing
emulated ARM64 does not work on this platform.

This runs the same five calls under plain uv Python before anything is frozen. Passing means
PyInstaller; failing means local Anisette cannot work on Windows at all, and the sign-in there
needs a remote Anisette server - a product decision rather than a build fix.

continue-on-error, because the answer is worth having either way and a red X here would bury
the frozen result underneath it.
The plain-Python run on the same machine emulates ARM64 and reads back 42, so unicorn works
here and the frozen build is what breaks it. What kills the frozen one is still unnamed
because bash reports any process Windows killed as 127 - which is bash's own "command not
found" and carries no information.

PowerShell keeps the NTSTATUS. 0xC0000005 is an access violation and 0xC0000409 is a security
check, meaning Control Flow Guard or a stack cookie - and for a library that JITs its own code
those point at completely different fixes.
…s not set

The previous commit turned this green over a binary that was still dying. A process Windows
kills leaves $LASTEXITCODE unset, `exit $null` is `exit 0`, and the step reported success
while the self-test never got past emu_start - the log shows the stages and then nothing, no
"read back 42" and no "self-test passed".

That is the worst outcome available: a check that goes green over the exact failure it was
written for reads as coverage and is worse than not having one.

Success is now the line the self-test prints when it finishes, and nothing else counts. The
exit code is still printed, because it is evidence, but it decides nothing.
Named at last. The frozen binary exits 0xC0000409 from `emu_start` - STATUS_STACK_BUFFER_OVERRUN,
which is what __fastfail reports for a security-check failure, and Control Flow Guard is the one
in play. Every observation fits: the DLL loads, the emulator constructs, memory maps and writes,
and the process dies the moment execution enters code that did not exist at link time and is
therefore not in CFG's table of valid indirect-call targets.

Two CI runs established it was not packaging. The same five calls under plain Python on the same
Windows runner emulate ARM64 and read back 42, so unicorn works there and the bundle carries
everything - it is freezing that breaks it. PyInstaller's bootloader is built with CFG on and the
flag is process-wide, so no packaging option avoids it.

The flag is one bit in the PE header. `scripts/clear_windows_cfg.py` clears it after the build, in
both the release workflow and the check, and refuses a file whose headers it does not recognise -
because a patch step that silently does nothing produces a binary that looks fixed and still dies.

It gives up a real mitigation, and the script says so rather than hiding it. The alternative is an
exporter that cannot sign in on Windows at all: emulating Apple's ADI library is how a login
happens without handing the exchange to a third-party Anisette server.

Eight tests against a synthetic PE, since the real one only exists on a Windows runner - covering
the bit, the neighbouring flags it must not touch, both header widths, and the three shapes of
wrong file.
@parawanderer
parawanderer merged commit 9a6876e into main Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant