Skip to content

Add Windows support - #496

Open
maxiboch wants to merge 2 commits into
CircleCI-Public:mainfrom
maxiboch:windows-support
Open

Add Windows support#496
maxiboch wants to merge 2 commits into
CircleCI-Public:mainfrom
maxiboch:windows-support

Conversation

@maxiboch

Copy link
Copy Markdown

Problem

GOOS=windows go build ./... fails on exactly two lines:

internal/telemetry/delegate.go:66:41: unknown field Setpgid in struct literal of type syscall.SysProcAttr
internal/sidecar/terminal.go:16:28: undefined: syscall.SIGWINCH

Nothing else in the tree blocks it. The module is CGO_ENABLED=0 pure Go with no existing
build constraints, zalando/go-keyring supports Windows Credential Manager, x/term
supports Windows consoles, and coder/websocket is pure Go.

Changes

internal/telemetry — moved the process-group call behind build tags as detachProcess:

  • Unix keeps SysProcAttr{Setpgid: true}.
  • Windows uses CreationFlags: CREATE_NEW_PROCESS_GROUP, the equivalent way to keep console
    control events sent to the parent's group away from the detached telemetry subprocess.

internal/sidecar — split terminal.go into terminal_unix.go (logic unchanged) and a
new terminal_windows.go.

Windows has no SIGWINCH, so the Windows build polls term.GetSize every 250 ms and calls
WindowChange only when the size actually differs. A no-op stub would also compile, but
remote PTY resize would then silently stop working on Windows — polling keeps the behaviour.
Happy to switch to a no-op if you would rather not carry a ticker.

.goreleaser.yaml — added windows to goos, and a format_overrides entry so Windows
archives ship as .zip.

Verification

check result
GOOS=windows GOARCH=amd64 go build ./... pass
GOOS=windows GOARCH=arm64 go build ./... pass
GOOS=darwin GOARCH=arm64 go build ./... pass
GOOS=linux GOARCH=amd64 go build ./... pass
GOOS=windows go vet ./internal/... clean
go test ./internal/... pass
gofmt -l . clean

What is not verified

This was cross-compiled and unit-tested from macOS. The polling resize path has not run on
a real Windows console.
If you have Windows CI or a reviewer on Windows, that is the part
worth exercising before merge.

Also note the two format_overrides / formats keys follow current goreleaser schema; if
your release pipeline pins an older version, that stanza may need the singular format key
instead.

`GOOS=windows go build ./...` currently fails on exactly two lines:

    internal/telemetry/delegate.go:66:41: unknown field Setpgid in struct
        literal of type syscall.SysProcAttr
    internal/sidecar/terminal.go:16:28: undefined: syscall.SIGWINCH

Everything else in the tree is already portable: it is CGO_ENABLED=0 pure Go
with no existing build constraints, go-keyring supports Windows Credential
Manager, x/term supports Windows consoles, and coder/websocket is pure Go.

telemetry: move the process-group call behind build tags as detachProcess.
Unix keeps SysProcAttr{Setpgid: true}; Windows uses CreationFlags with
CREATE_NEW_PROCESS_GROUP, which is the equivalent way to keep console
control events sent to the parent's group away from the child.

sidecar: split terminal.go into terminal_unix.go (unchanged) and a new
terminal_windows.go. Windows has no SIGWINCH, so the Windows build polls
term.GetSize every 250ms and calls WindowChange only when the size actually
differs. A no-op stub would also compile, but remote PTY resize would then
silently stop working on Windows.

goreleaser: add windows to goos, and ship Windows archives as .zip.

Verified: builds pass for windows/amd64, windows/arm64, darwin/arm64 and
linux/amd64; go vet is clean under GOOS=windows; existing tests pass.

Not verified: the polling resize path has not been exercised on a real
Windows console — this was cross-compiled and unit-tested from macOS.
@maxiboch

Copy link
Copy Markdown
Author

Follow-up: the PR body said the polling resize path and the process-group change had only been
cross-compiled and never run on Windows. I've now exercised the build on two independent
Windows machines. Reporting what passed and, more importantly, what did not.

Environments

A B
Windows 11, build 10.0.26100.8894 11, build 10.0.26200.8875
CPU Ryzen AI 9 HX 370 Ryzen AI Max+ 395
sshd default shell cmd.exe (stock) Git-Bash (DefaultShell set)

Independently configured — different builds, different hardware, different shells.

The binary runs natively

> chunk.exe --version
chunk version dev
EXIT=0

Exit 0 on both machines, full cobra command tree loads. windows/amd64 build from this branch.

Package tests, run natively

Cross-compiled go test -c for windows/amd64 and executed on the hosts.

A B
internal/sidecar 81 pass / 7 fail 6 fail

Six failures are identical across both machines, so they are not environment-specific:

  • TestStatOrEmptyPermissionsError
  • TestSync_FetchBeforeReset
  • TestSync_ApplyWhitespaceNowarn
  • TestBundleSync_SendsBundle
  • TestBundleSync_NoOpSkipsBundle
  • TestBundleSync_ApplyWhitespaceNowarn

(A had a seventh, TestSync_NonApplyFailureReturnsImmediately, which did not reproduce on B.)

None of them are in the files this PR changes. terminal_windows.go / terminal_unix.go
and detach_windows.go / detach_unix.go have no test coverage in either direction — the
failures are in active_test.go and sync_test.go, untouched here.

Two distinct causes, neither caused by this patch:

  1. TestStatOrEmptyPermissionsError reads its own source file by the absolute path baked
    in at compile time, which does not exist on the target when the test binary is
    cross-compiled. It also asserts a Unix permission behaviour that has no direct Windows
    analogue. Partly an artefact of how I ran it.
  2. The five sync failures all abort at
    ssh: handshake failed: host key mismatch for 127.0.0.1, with a different "got" hash each
    time. TestTofuHostKeyCallback passes, so the TOFU logic itself is fine — this looks like
    fixture state (a known_hosts written once, then a fresh in-process server key per test)
    rather than a defect in the code under test. I did not chase it to ground.

So: this PR makes chunk build and run on Windows. It does not make the test suite green
there.
That is pre-existing and separable, but you should know it before merging rather than
after.

Not verified

  • The polling resize path. Exercising it needs an interactive PTY against a live sidecar,
    which I don't have. watchWindowSize has no test in either build. This remains the least
    proven part of the change.
  • internal/telemetry tests — they shell out to go to build a receiverbin fixture, and
    neither box has a Go toolchain. So CREATE_NEW_PROCESS_GROUP is compiled and linked but its
    runtime behaviour under Ctrl-C is still unproven.

Happy to swap the resize implementation for a no-op stub if you would rather not carry a
ticker for an untested path — the offer in the PR description stands.

@maxiboch

Copy link
Copy Markdown
Author

One refinement on the sync failures, to save anyone a wrong lead.

I suggested they might be stale fixture state — a known_hosts written once and then mismatching against a fresh in-process server key per test. That is not it. Deleting %USERPROFILE%\.ssh\known_hosts and the app's local state directory, then running only -test.run "TestSync|TestBundleSync", reproduces all of them from clean. And the real user known_hosts contains no 127.0.0.1 entries afterwards, so the suite is not writing there either.

So: reproducible from clean state on two independently configured Windows machines, and not explained by leftover state. I don't have a mechanism for you beyond that — flagging it as an open question rather than a diagnosis, since these are untouched by this PR either way.

TestMain builds the receiverbin stub with an explicit `-o` path, and
`go build` does not append `.exe` when the output path is given. Windows
then cannot execute the result: os/exec appends extensions from PATHEXT
when looking up a name that has none, so it searches for receiverbin.exe
and finds nothing.

The stub therefore built successfully and never started, and
TestDelegateDestination_Close_DeliversToSegment failed with "received 0
events, want 1" after its 5s timeout.

Verified on Windows 11 (10.0.26200) with go1.26.5 windows/amd64: the
package fails before this change and passes after it. Confirmed by
substituting a no-op detachProcess that the failure was independent of
this branch's CREATE_NEW_PROCESS_GROUP change.

No behaviour change off Windows.
@maxiboch

Copy link
Copy Markdown
Author

Pushed b3b4809, and correcting two things I said in my previous comment now that I have a
Go toolchain on the Windows boxes and can build and test natively rather than running
cross-compiled test binaries.

The telemetry failure was a real bug, and it is fixed

internal/telemetry failed on Windows with
TestDelegateDestination_Close_DeliversToSegment: received 0 events, want 1.

That is precisely the test that exercises the detachProcess change in this PR, so the
obvious reading was that CREATE_NEW_PROCESS_GROUP broke it. It did not. I substituted a
no-op detachProcess and got the identical failure, which rules this branch out.

The actual cause is in delegate_test.go:

receiverBinPath = filepath.Join(dir, "receiverbin")
build := exec.Command("go", "build", "-o", receiverBinPath, "./testdata/receiverbin")

go build does not append .exe when -o is explicit. Windows os/exec then appends
PATHEXT extensions when resolving a name with none, searches for receiverbin.exe, and finds
nothing. So the stub built successfully and never started, and the test waited out its 5s
timeout against zero events.

b3b4809 adds the extension on Windows. internal/telemetry now passes natively on both
machines. No behaviour change off Windows; go test ./internal/... still green on macOS, and
windows/amd64, darwin/arm64, linux/amd64 all still build.

Consequence worth stating plainly: this also means CREATE_NEW_PROCESS_GROUP is now
verified at runtime, not merely compiled.
With the real implementation in place and the
fixture fixed, the detached telemetry subprocess spawns and delivers. That was the least
proven part of this PR and it is no longer unproven.

Correction: TestStatOrEmptyPermissionsError is not a cross-compile artefact

I previously wrote that it failed because a cross-compiled test binary looks for its own
source at the absolute build-time path. That explanation was wrong. Built and run natively
on Windows, it still fails, with a different and more honest message:

active_test.go:52: assertion failed: err is nil: expected error for inaccessible directory, got nil

It chmods a directory to make it unreadable and expects an error. Windows does not honour
POSIX mode bits that way, so the directory stays readable and no error occurs. That is a
genuine platform gap in the test, not an artefact of how I ran it.

Still failing, still not mine

The five Sync/BundleSync tests fail natively too, identically on both machines, at
ssh: handshake failed: host key mismatch for 127.0.0.1. Not stale state — reproducible from
clean, and the suite does not write to the user's known_hosts. I have not diagnosed it.

Current native state on Windows:

package result
internal/telemetry pass
internal/sidecar 7 fail — 1 POSIX-permissions assumption, 6 SSH fixture

Both remaining classes are independent of this branch. I am happy to take either on in a
separate PR if useful, but did not want to widen this one further without asking.

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