Windows build¶
The contract iiiris maintains for first-class Windows support:
which artifacts ship, what the installer guarantees, how the
service-mode runtime behaves, and the build pipeline that produces
all of it. This spec replaces docs/briefs/WINDOWS_BUILD.md now
that Phases 3a + 3b + 3c have shipped end-to-end.
The operator-facing reference lives in
../deployment.md — install walkthroughs,
upgrade procedures, service-mode operations. This spec captures
what the implementation guarantees; that file captures how to
use it.
Audiences and artifacts¶
Three audiences, three artifacts. Each tagged release publishes all three.
| Artifact | Audience | Form |
|---|---|---|
| Source build | Contributors, self-builders | make build from an MSYS2 UCRT64 shell (see ../deployment.md). |
iiirisd-vX.Y.Z-windows-amd64.zip |
Operators comfortable extracting a zip | iiirisd.exe + ~90 MSYS2 UCRT64 DLLs + LICENSE/NOTICE/THIRD_PARTY_LICENSES.md. Extract and run. |
iiirisd-vX.Y.Z-windows-amd64.msi |
Production deployers | WiX v5 MSI. Per-user or all-users layout. Optional service-install mode with Event Log + rolling file logging. Self-signed. |
Build pipeline contract¶
- Toolchain: MSYS2 UCRT64.
mingw-w64-ucrt-x86_64-libvips,mingw-w64-ucrt-x86_64-gcc,mingw-w64-ucrt-x86_64-pkg-config,mingw-w64-ucrt-x86_64-openjpeg2.govips(CGO) is the only reason CGO is needed; the build is otherwise pure Go. Pinned by the Phase 1.1 spike at Go 1.25.0 / gcc 16.1.0 / libvips 8.18.2. - Runner: GitLab SaaS Windows pool (
saas-windows-medium-amd64). Cross-compiling CGO + libvips from Linux is impractical; Windows builds happen on Windows. - Linkage: dynamic.
iiirisd.exedepends on ~90 MSYS2 UCRT64 DLLs at runtime; the zip and MSI both bundle them alongside the binary. LGPL §6(d) is satisfied trivially by drop-in replacement of the libvips DLL. - DLL inventory:
deploy/installer/dlls.wxsis generated from the staged binary'slddclosure byscripts/wix-generate-dlls.ps1. The committed file is checked for staleness in CI (-Mode Check); MSYS2 dep drift fails the build with an actionable diff. Reviewer regenerates +commits before the build can pass. - CI cadence:
build-windows-amd64(zip) runs on release tags and on MRs touching Windows-relevant paths (cmd/iiirisd/**,go.mod,deploy/installer/**, the Windowsscripts/*.ps1,.gitlab-ci.yml) — the MSYS2 + libvips + Go toolchain is installed per job (~15-20 min cold start), too costly to run on every push.build-windows-msi(MSI) runs on release tags — blocking there, so a broken installer fails the release — and is hand-runnable (manual,allow_failure) on MRs touchingdeploy/installer/**or the Windows packaging scripts — until that existed, nothing exercised the MSI before a release tag already depended on it. The MSI job expands the zipbuild-windows-amd64publishes, so it bundles exactly the same binary + DLL set the zip ships. (The zip is the artifact; the staging directory it was built from never crosses the job boundary.) The runner is the SaaS Windows pool; the former ephemeral-EC2 orchestrator is retired from CI (itsdeploy/aws,deploy/packer,scripts/awsfiles remain in the repo, unused).
MSI installer contract¶
Layout¶
- Single MSI, two install scopes (WiX single-package
authoring via
ALLUSERS=2 MSIINSTALLPERUSER=…): - All-users — binary at
C:\Program Files\iiiris\, data atC:\ProgramData\iiiris\{config,cache,logs}\. Per-machine, requires Administrator. Default. - Per-user — binary at
%LocalAppData%\iiiris\, data still at%ProgramData%\iiiris\. No admin needed. Service-install mode is unavailable (HKLM writes required). - Layout chosen via
WixUI_Advanced'sWhichUsersDlg(per-machine default; per-user requires explicit user selection).
Default config¶
config.yaml.example(commented Windows-aware template) ships ininstaller/and lands at%ProgramData%\iiiris\config\config.yamlon first install only.NeverOverwrite="yes"+Permanent="yes"together mean: the example never replaces operator edits, and uninstall preserves the (possibly-edited) config.
Data dirs¶
%ProgramData%\iiiris\{config,cache,logs}\ are created on
install with Permanent="yes" on each Component. Uninstall
preserves all three. Operators wanting a clean wipe remove
%ProgramData%\iiiris\ manually after uninstall.
Service mode¶
msiexec /i … INSTALLSERVICE=1 on an all-users install
registers a Windows service:
| Property | Value |
|---|---|
| Service name | iiiris |
| Display name | iiiris IIIF Image Server |
| Type | ownProcess |
| Start type | auto |
| Account | LocalSystem (ServiceInstall leaves StartName null, which is how CreateService selects it) |
| Image path | <install dir>\iiirisd.exe -config "%ProgramData%\iiiris\config\config.yaml" |
The service is started by the installer (ServiceControl
Start="install" Wait="yes"), stopped before file replacement and
on uninstall (Stop="both"), and removed on uninstall
(Remove="uninstall"). Failure to register is fatal to the
install (Vital="yes") — asking for a service and silently not
getting one is the defect this contract exists to prevent.
Without INSTALLSERVICE=1 the installer registers no service and
the binary installs exactly as before.
Why the authoring looks the way it does. MSI applies a
ServiceInstall row only when the row's component has the service
executable as its key path (ServiceInstall table,
Component_ column). iiirisd.exe must install unconditionally,
so the component that owns it cannot carry the optionality. The
rows therefore live on that component, and optionality lives on
the sequence: StopServices, DeleteServices,
InstallServices and StartServices are each conditioned
VersionNT AND IIIRISSERVICE. With IIIRISSERVICE unset the row
is never applied and no service exists, while the binary still
installs.
IIIRISSERVICE is computed once after AppSearch from either
INSTALLSERVICE=1 on a per-machine install, or an
HKLM\SOFTWARE\iiiris\ServiceInstalled marker written by a
previous install. The marker is load-bearing: INSTALLSERVICE is
not passed when an operator double-clicks the next release, nor on
uninstall, so without it an upgrade would skip StopServices and
fail to replace a binary the running service holds open, and
uninstall would leave the service registered.
Contract points:
- Per-user installs never register a service and never fail
trying. A non-elevated install cannot write to the SCM
(
InstallServices"requires that the user be an administrator"; error 1923, fatal), so the conditioned actions do not run. - The registration is MSI-owned, not
sc.exe-owned: install rollback, upgrade stop/start, and uninstall removal are all standard-action behaviour. No custom actions are involved. - The package deliberately fails ICE105, which requires that a
dual-purpose (
perUserOrMachine) package install no services. No single dual-purpose MSI can offer an optional per-machine service and satisfy it; the hazard it guards — a per-user install attempting SCM writes — is what the sequence conditions prevent.wix builddoes not run ICE validation (that is the separatewix msi validatesubcommand), so nothing in CI depends on this. ServiceInstallauthors noAccount. LeavingStartNamenull is howCreateServiceis told to use LocalSystem. Naming the account explicitly (NT AUTHORITY\LocalSystem) makesCreateServicetry to log the service on as that literal account and fail — reported as "Error 1923 … Verify that you have sufficient privileges", which is misleading, since it happens on an elevated install. Do not "fix" this by adding the attribute back.ServiceControlusesRemove="uninstall", not"both"— the service is reconfigured in place on reinstall/upgrade rather than deleted and recreated, so operator changes made throughservices.msc(service account, recovery actions) survive.
Service mode itself is unchanged: iiirisd.exe detects SCM
context and runs as a service when launched by one, whether the
MSI registered it or an operator did with sc.exe (still
supported and documented in
docs/deployment.md for zip
installs and for adding a service to an existing binary install).
Event Log source¶
Every all-users install writes two REG values under
HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\iiiris
(per-user installs skip them — no HKLM access):
EventMessageFile(REG_EXPAND_SZ) = path of the installediiirisd.exe. No embedded message table; Windows logs records verbatim.TypesSupported(REG_DWORD) =7(Info | Warning | Error).
internal/observability.EventLogHandler opens this source at
runtime. The keys are registered independently of any service so
that a hand-registered service logs to the Event Log without the
operator adding them. If the source is absent (zip installs,
per-user installs that can't write HKLM), the handler returns
nil and falls back to file + stderr — graceful degradation by
design.
Upgrade¶
MajorUpgrade Schedule="afterInstallExecute":
- New install begins.
StopServicesstops theiiirisservice, releasing the lock oniiirisd.exe.InstallFilesreplacesiiirisd.exe+ bundled DLLs.InstallServicesreconfigures the service against the new binary;StartServicesstarts it again.RemoveExistingProductsremoves the old MSI's product entry; same-Guid components are no-ops, so the service registration is not torn down with them.
Steps 2 and 4 apply only when a service is registered — decided
by the persisted HKLM\SOFTWARE\iiiris\ServiceInstalled marker,
so the operator does not have to re-pass INSTALLSERVICE=1 on
every upgrade. A service registered by hand with sc.exe is
not covered: it is invisible to the marker, so the operator
stops it before upgrading.
Brief downtime window (seconds). Single-host install assumption; no rolling-upgrade pattern.
Uninstall¶
Removes the binary, bundled DLLs, Start Menu shortcuts, service
registration (when present), Event Log source registry keys.
Preserves all Permanent="yes" components:
%ProgramData%\iiiris\{config,cache,logs}\ plus the operator's
config.yaml. Manual Remove-Item -Recurse "%ProgramData%\iiiris"
is the clean-wipe escape hatch.
Start Menu shortcuts¶
Three entries under Start Menu → iiiris\:
- "iiiris IIIF Server" — console-mode launch. Binary-install
only (its Component is conditioned
NOT IIIRISSERVICE; a foreground launch would race the running service for the configured port). - "Open iiiris admin" — Internet shortcut to
http://localhost:8080/admin/. Always present; useful onceIIIRIS_ADMIN_USER/IIIRIS_ADMIN_PASSare set. - "Uninstall iiiris" —
msiexec /x [ProductCode]. Always present.
Code signing¶
- Self-signed for the first cut. A future EV/OV cert
procurement is a drop-in replacement (same CI secrets, the cert
just changes;
SIGN_MSI=1stays). - SmartScreen shows "Unknown publisher" on first run; users click-through via "More info → Run anyway" or right-click → Properties → Unblock. The self-signature proves the artifact wasn't tampered after CI built it.
- CI gate:
SIGN_MSIvariable, default"0". When"1",scripts/sign-msi.ps1runssigntool sign /f <pfx> /p <pass> /tr http://timestamp.digicert.com /td sha256 /fd sha256and verifies viasigntool verify /pa.
Runtime contract (Windows service mode)¶
Phase 3a's Go-side surface in cmd/iiirisd/:
- SCM context detection:
isWindowsService()(viagolang.org/x/sys/windows/svc.IsWindowsService) returns true when launched by SCM. Errors from the underlying API are treated as "not a service" so the binary falls back to console mode rather than wedging. - Service handler:
runService(configPath)reusessetup(cfg, configPath, extras...)andserve(bundle, stop)from the console path. Extras = Event Log handler + rolling-file handler. The handler translates SCMStop/Shutdowncontrol events into a stop-channel close thatserve()blocks on. Exit code flows back to SCM. - Build-tagged files:
service_{windows,unix}.go,is_service_{windows,unix}.go,signals_{windows,unix}.go. Non-Windows stubs keep the package buildable; runtime dispatch never reaches them.
Portability fixes (Phase 1)¶
Universal cross-platform corrections that landed during Phase 1 and remain part of the contract:
signals_unix.go/signals_windows.go—SIGTERMis Unix-only; the Windows path usesos.Interrupt. Build-tagged helpershutdownSignals()returns the right set per OS.source.safePathrejects on every platform:- NUL bytes (defense-in-depth).
- Colon
:(Windows drive-letter injection + NTFS alternate data streams). - Reserved DOS device names (
CON,PRN,AUX,NUL,COM0-9,LPT0-9) in any path component, stem-matched with trailing dot/space stripped. Applies universally so Linux-authored collections stay portable. cache.Filesystem.Putnotes the Windows rename sharing-violation edge (open file during rename); the actual retry wrapper lands if realistic-load smoke surfaces the failure mode.
Smoke test contract¶
scripts/smoke-msi.ps1 runs after every build-windows-msi
build. All installs use msiexec /qn ALLUSERS=2
MSIINSTALLPERUSER="" — the empty property is what selects
all-users; ALLUSERS=2 alone installs per-user.
Subtest 1 — binary install (no INSTALLSERVICE): assert the
binary, data dirs and default config land; the Event Log source
keys exist with the right TypesSupported / EventMessageFile;
no service named iiiris exists and no HKLM marker was
written; the console Start Menu shortcut is present. Run the
installed binary, /health, one IIIF render. Uninstall and
assert data preserved and the Event Log keys gone.
Subtest 2 — service install (INSTALLSERVICE=1): assert a
running, Auto-start, LocalSystem service named iiiris whose
image path is the installed iiirisd.exe with the -config
argument, and that the HKLM marker was written. /health and one
IIIF render against the service, so SCM-hosted iiirisd is
exercised rather than inferred. Assert the console shortcut is
absent. Reinstall in place (REINSTALL=ALL) and assert the
service survives, still running — the upgrade-shaped path.
Uninstall and assert the service is stopped and removed, the
marker cleared, and data preserved.
The subtest rewrites the installed config.yaml before the
service install (a service inherits no environment from the
harness; NeverOverwrite="yes" means the MSI leaves the rewrite
alone).
The job's CI artifacts include every install/reinstall/uninstall log, the installed binary's stdio, the service's own log file, and all rendered JPEGs — enough to diagnose any failure without reproducing locally.
A separate scripts/smoke-windows.ps1 (per-format + console +
service-via-sc.exe lifecycle) runs after build-windows-amd64
and covers the staged-binary surface independent of the MSI.
Out of scope¶
- Cross-compiling Windows from Linux. CGO + libvips on mingw-on-Linux is a tarpit; the project explicitly opted out. Windows builds happen on Windows.
- macOS installer. Source build only; no published artifact.
- Multi-architecture Windows.
windows/amd64only.windows/arm64lands if demand materialises. - Auto-update / update channels. Each MSI is a manual
download.
MajorUpgradehandles the install-over-install case; nothing phones home. - A GUI checkbox for service mode.
INSTALLSERVICE=1is a command-line property only; theWixUI_Advancedwizard offers no service page. - Turning an installed service off through the installer.
INSTALLSERVICE=0on an upgrade is not honoured — the persisted marker wins. Uninstall the product, orsc.exe delete iiiris. - Reconciling a hand-registered
sc.exeservice with the installer. The marker only records services the MSI created; one added by hand stays the operator's to stop and remove. - PerfMon / ETW tracing. No Windows-specific telemetry beyond what slog + Event Log already provide.
- HKEY_LOCAL_MACHINE writes beyond the Event Log source and the
service. The installer's own registry authoring is the Event
Log source keys plus two synthetic component key paths under
HKLM\SOFTWARE\iiiris(EventLogSourceInstalled,ServiceInstalled). Nothing else. The service's ownHKLM\SYSTEM\CurrentControlSet\Services\iiiriskey is written by MSI'sInstallServices, not authored here.