A nil writer is one of those values that looks like "I don't care about this output" and behaves like "please crash." In GitLab Fleeting's WinRM connector, that's exactly what happened. The library always copied command output to stdout. When stdout was nil, the process panicked.
This is the story of gitlab-org/fleeting/fleeting!151, my fix for that panic, opened during the October 2026 Hackathon. I'll cover the issue, the test that reproduced it before the fix existed, the one-guard change, and the fake WinRM transport that made the tests possible.
The issue: a panic on a path that meant "no stdout"
The issue was gitlab-org/fleeting/fleeting#66, opened by Brandon Bruce. It came from GitLab Runner's instance_ready_command path for Windows autoscaling (support case 759678).
Fleeting is the library GitLab Runner uses to provision and connect to autoscaled instances. The WinRM connector is how it talks to Windows. winRMClient.Run took opts.Stdout and handed it to the winrm library. The library copies command output to that writer unconditionally. When Stdout was nil, the copy was a nil pointer dereference.
Nil Stderr was already handled. Nil Stdout was not. An earlier MR, !117, had tightened stderr-writer handling and concurrent writes, and had left this hole.
The plan: reproduce it first
I wanted this issue for the October 2026 Hackathon, and I didn't want to guess about the panic. I commented with a plan that started from a failing test:
I would like to work on this for the October 2026 Hackathon. I reproduced the panic on
mainwith a unit test that driveswinRMClient.Runthrough a fakewinrm.TransporterwithStdout: nil(it crashes inio.CopyinsideRunWithContextWithInput). Planned fix: default a nilopts.Stdouttoio.DiscardinRun, plus that regression test.
The contributor platform assigned me, and I opened the MR the same day.
The guard
The production change is a nil check at the top of Run, before anything is passed to the library:
func (c *winRMClient) Run(ctx context.Context, opts RunOptions) error { // winrm copies the command output to stdout unconditionally, so a nil // writer would panic. if opts.Stdout == nil { opts.Stdout = io.Discard } var stderrWriter *stderrOmitWriter if opts.Stderr == nil { stderrWriter = newStderrOmitWriter() io.Discard is the right default here: the caller asked to run a command and didn't provide a place for stdout, so the output should be thrown away, not written into a nil interface. Stderr already had its own guard, a stderrOmitWriter. I didn't try to unify the two. I made stdout safe in the same place — at the start of Run — with the simplest writer that cannot panic.
A fake SOAP transport, so the test doesn't need Windows
A unit test for this can't talk to a real WinRM endpoint. The winrm client speaks SOAP: it creates a shell, starts a command, and receives streams. I added connector/winrm_test.go with a fake transport that answers those three requests and ignores the rest:
// fakeWinRMTransport answers WinRM SOAP requests with canned responses for a // single command that writes "hello" to stdout and exits with exitCode. type fakeWinRMTransport struct{} func (fakeWinRMTransport) Transport(*winrm.Endpoint) error { return nil } func (fakeWinRMTransport) Post(_ *winrm.Client, msg *soap.SoapMessage) (string, error) { body := msg.String() switch { case strings.Contains(body, "transfer/Create"): return fakeWinRMCreateShellResponse, nil case strings.Contains(body, "shell/Command"): return fakeWinRMCommandResponse, nil case strings.Contains(body, "shell/Receive"): return fakeWinRMReceiveResponse, nil default: // Signal and Delete responses are not parsed by the client. return "", nil } } The receive response carries a base64 stdout stream of hello (aGVsbG8=) and an exit code of 0. The client is built with TransportDecorator so it uses the fake instead of the network:
params := winrm.NewParameters("PT60S", "en-US", 153600) params.TransportDecorator = func() winrm.Transporter { return fakeWinRMTransport{} } Two examples sit on top of that:
func TestWinRMRunNilWriters(t *testing.T) { client := newFakeWinRMClient(t) require.NotPanics(t, func() { err := client.Run(context.Background(), RunOptions{Command: "echo hello"}) require.NoError(t, err) }) } func TestWinRMRunWritesStdout(t *testing.T) { client := newFakeWinRMClient(t) var stdout strings.Builder err := client.Run(context.Background(), RunOptions{Command: "echo hello", Stdout: &stdout}) require.NoError(t, err) require.Equal(t, "hello", stdout.String()) } The first one is the regression test. Without the guard it panics with invalid memory address or nil pointer dereference. With the guard it passes. The second one is there so the discard path can't accidentally swallow output when a writer is provided.
I ran go test ./... -count=1 and golangci-lint run ./connector/ locally, and both were clean.
Review and merge
GitLab Duo reviewed the MR and had no change requests. It checked that a nil Stdout is replaced with io.Discard before it reaches runWinRMCmd, that other callers of runWinRMCmd (DialWinRM, DialRun, deployTunnelBinary) already pass non-nil writers, that the fake transport's substring routing doesn't overlap, and that TestWinRMRunWritesStdout writes to a strings.Builder from a single goroutine.
I marked the MR ready for a maintainer. Arran Walker reviewed it, thanked me, and merged it on 14 October 2026, two days after I opened it.
What I took away
1. Reproduce a panic with a test before you write the fix. The issue comment already said where it crashed (io.Copy inside RunWithContextWithInput). A NotPanics example on main turns that into something you can watch go from red to green.
2. Guard nil writers at the boundary you own. The panic was inside a library that always writes to stdout. Fleeting can't change that library's contract, but it can stop passing nil in.
3. io.Discard is the nil stdout. If the caller didn't provide a writer, throwing the bytes away is the behavior they asked for. Don't invent a more clever sink.
4. Fake the protocol at the transport boundary. A WinRM unit test doesn't need a Windows host if you can answer Create, Command, and Receive with canned SOAP. Keep the routing strings tight so they don't overlap.
5. Keep a positive example next to the panic example. TestWinRMRunNilWriters proves we don't crash. TestWinRMRunWritesStdout proves we still write when we should.
Links
- Issue: gitlab-org/fleeting/fleeting#66
- Merge request: gitlab-org/fleeting/fleeting!151
- The earlier stderr-handling work: gitlab-org/fleeting/fleeting!117
Thanks to Brandon Bruce for the issue, and to Arran Walker for the review and the merge.
If you've been thinking about contributing to GitLab, the community issues are a great place to start: contributors.gitlab.com.



