Skip to content

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.exe depends 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.wxs is generated from the staged binary's ldd closure by scripts/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 Windows scripts/*.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 touching deploy/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 zip build-windows-amd64 publishes, 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 (its deploy/aws, deploy/packer, scripts/aws files 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 at C:\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's WhichUsersDlg (per-machine default; per-user requires explicit user selection).

Default config

  • config.yaml.example (commented Windows-aware template) ships in installer/ and lands at %ProgramData%\iiiris\config\config.yaml on 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 build does not run ICE validation (that is the separate wix msi validate subcommand), so nothing in CI depends on this.
  • ServiceInstall authors no Account. Leaving StartName null is how CreateService is told to use LocalSystem. Naming the account explicitly (NT AUTHORITY\LocalSystem) makes CreateService try 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.
  • ServiceControl uses Remove="uninstall", not "both" — the service is reconfigured in place on reinstall/upgrade rather than deleted and recreated, so operator changes made through services.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 installed iiirisd.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":

  1. New install begins.
  2. StopServices stops the iiiris service, releasing the lock on iiirisd.exe.
  3. InstallFiles replaces iiirisd.exe + bundled DLLs.
  4. InstallServices reconfigures the service against the new binary; StartServices starts it again.
  5. RemoveExistingProducts removes 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 once IIIRIS_ADMIN_USER / IIIRIS_ADMIN_PASS are 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=1 stays).
  • 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_MSI variable, default "0". When "1", scripts/sign-msi.ps1 runs signtool sign /f <pfx> /p <pass> /tr http://timestamp.digicert.com /td sha256 /fd sha256 and verifies via signtool verify /pa.

Runtime contract (Windows service mode)

Phase 3a's Go-side surface in cmd/iiirisd/:

  • SCM context detection: isWindowsService() (via golang.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) reuses setup(cfg, configPath, extras...) and serve(bundle, stop) from the console path. Extras = Event Log handler + rolling-file handler. The handler translates SCM Stop / Shutdown control events into a stop-channel close that serve() 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.goSIGTERM is Unix-only; the Windows path uses os.Interrupt. Build-tagged helper shutdownSignals() returns the right set per OS.
  • source.safePath rejects 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.Put notes 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/amd64 only. windows/arm64 lands if demand materialises.
  • Auto-update / update channels. Each MSI is a manual download. MajorUpgrade handles the install-over-install case; nothing phones home.
  • A GUI checkbox for service mode. INSTALLSERVICE=1 is a command-line property only; the WixUI_Advanced wizard offers no service page.
  • Turning an installed service off through the installer. INSTALLSERVICE=0 on an upgrade is not honoured — the persisted marker wins. Uninstall the product, or sc.exe delete iiiris.
  • Reconciling a hand-registered sc.exe service 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 own HKLM\SYSTEM\CurrentControlSet\Services\iiiris key is written by MSI's InstallServices, not authored here.