Skip to content

Wine - #3602

Closed
PeterNeiss wants to merge 86 commits into
thought-machine:masterfrom
PeterNeiss:wine
Closed

Wine#3602
PeterNeiss wants to merge 86 commits into
thought-machine:masterfrom
PeterNeiss:wine

Conversation

@PeterNeiss

Copy link
Copy Markdown

No description provided.

Peter Neiss and others added 30 commits September 10, 2026 08:47
Scopes a Windows port with C++ builds as the driving use case, developed and
tested on Linux via cross-compilation. Ten milestones across two repos
(please + please-build/cc-rules), with five recorded decisions.

M0 was measured rather than predicted, and the results reshaped the plan:

- Compile blockers are 5 sites in 4 packages, not the ~11 the source survey
  suggested. They are layered, not parallel: src/process is a dependency of
  nearly everything, so a single `go build ./...` leaves 30 of 51 packages
  unchecked.
- syscall.Exec, syscall.Chdir and the signal constants all compile on Windows
  (Go ships stubs returning EWINDOWS). They fail at runtime instead, which is
  harder to catch, not easier.
- go-flags uses '/' as its option delimiter on Windows, which breaks Please's
  entire label syntax: //pkg:target parses as option /pkg with argument
  target. Fixed by -tags forceposix (D5).
- src/output/shell_output.go reaches into cmd.SysProcAttr from outside
  src/process -- an abstraction leak the survey missed.
- busybox-w64 ships a bash applet but rejects --noprofile/--norc, unlike
  Linux busybox. Configurable ShellArgs is a requirement, not a hedge.

With those addressed, please.exe parses BUILD files and runs builds under
Wine, including the find|sort|tr pipeline the cc rules depend on. M1 is
re-estimated from 2-3 weeks to 1-2.

probe/m1-skeleton.patch records the minimal changes used to get there. It is
not an implementation -- its lock_windows.go is a no-op that would corrupt
concurrent builds.

No source files are touched by this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Two changes needed before anything else can be tested on Windows.

src/cli/logging.go used path.Dir on logFile, which is a real filesystem path,
not a build label. On Windows that returns "." for any backslash-separated
path, so MkdirAll creates the wrong directory and plz dies at startup with
"Error opening log file: ... Path not found". filepath.Dir is correct on every
platform; this happened to be harmless on POSIX.

.plzconfig_windows_amd64 sets BuildTags = forceposix for the go plugin.
go-flags uses '/' as its option delimiter and ':' as its name/argument
delimiter on Windows, so //pkg:target parses as option /pkg with argument
target and //... is rejected outright -- every command taking a build label is
broken without this. Note go_binary has no tags parameter; the go plugin reads
CONFIG.GO.BUILD_TAGS, so this has to be config rather than a per-target edit,
and scoping it to the arch config keeps it off other platforms.

The file also carries the MinGW toolchain settings, empty defaultldflags
(-lpthread and -ldl are both wrong there), and disables xattrs and the sandbox.

Docs updated: D5 previously described forceposix as a BUILD-file change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
These are the four things that stopped plz compiling for GOOS=windows, plus
the shell flags needed to make a build actually run there.

Process control. Windows has no process group that descendants inherit, so
killing a tree needs a job object: every process assigned to one dies together
on TerminateJobObject, and JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE covers the case
where we exit abnormally without cleaning up, which is what Pdeathsig gives us
on Linux. SIGTERM maps to a Ctrl-Break on the console process group, keeping
the existing graceful-then-forceful sequence in killProcess intact. There is
an unavoidable race assigning the job after Start(); closing it would need
CREATE_SUSPENDED, which os/exec gives us no way to do. Noted in the code.

File locking. LockFileEx replaces flock. It locks a byte range rather than a
file, so we take a single byte far past any content and leave the PID written
in the lock file readable by other processes -- that is what produces the
"process N has already acquired the lock" message. Unlike flock it cannot
convert between shared and exclusive atomically, so we drop and re-take;
acquireRepoLock only changes mode at startup so this isn't contended.

src/output/shell_output.go was reaching into cmd.SysProcAttr.Setpgid from
outside src/process. Replaced with process.ShareParentProcessGroup, so the
platform detail stays in one package.

clean's ForkExec becomes a detached exec.Command, with DETACHED_PROCESS on
Windows so the async delete isn't killed with our console.

Shell flags are now platform-specific: busybox, which is what we will ship as
the Windows shell, rejects --noprofile and --norc outright. It reads no
profile or rc files anyway, so nothing is lost. Note this is about the shell
being invoked, not the host -- remote execution always talks to a real bash on
the worker, so it keeps the full flag set via a new RemoteBashCommand.

lock_test.go now uses the portable constants, which lets the core tests
cross-compile. All twelve lock tests pass under Wine, including both mode
transitions and the non-blocking contention case, so LockFileEx is genuinely
excluding rather than silently succeeding.

Linux behaviour is unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Tracker updated for the process, locking, clean and shell-args work.

Also documents a trap that produced a false pass during M0: rm -rf plz-out is
not enough to force a cold build under Wine, because Please's directory cache
lives in the Wine prefix under AppData/Local/please. A build can look like it
succeeded while replaying artifacts from an earlier, differently-built binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
None of these stopped the Windows build: Go's syscall package ships Windows
stubs for Exec and Chdir, and defines the signal constants, so every one of
them compiled and would have failed only at runtime. That makes them easy to
miss, so they are covered by running the binary rather than by the compiler.

process.ExecReplace replaces the five syscall.Exec calls (plz op, plz tool,
plz run, plz update, please_shim). On Unix it is still syscall.Exec. On
Windows there is no way to replace a process image, so it runs the command as
a child and exits with its status; stdout passthrough and exit codes 0 and 3
were verified under Wine.

That difference has a consequence at the update call site. On Unix the exec
drops the repo lock for us, because Go opens files O_CLOEXEC. On Windows we
stay alive as the new process's parent, so the exclusive lock update holds
would deadlock the binary it just launched. The lock is now released
explicitly before handing over, which is what already happened implicitly
elsewhere.

Signal handling is now per-platform. Windows only ever delivers Ctrl-C as
os.Interrupt and a synthesised SIGTERM; SIGHUP, SIGQUIT and SIGABRT are
defined but never sent. The 128+signum exit convention is a shell idiom with
no meaning there, so it reports a plain failure instead.

core.LookPath was searching PATH entries split on a literal ":" and matching
exact filenames, so on Windows it would neither split C:\foo correctly nor
find bash.exe when asked for bash. It now uses the platform list separator via
fs.SplitPathList -- promoted from the private helper that already existed for
the FreeBSD fallback -- and tries the PATHEXT candidates from
fs.ExecutableNames. Note the other ":"-splitting sites are untouched; those
are a separate pass.

Xattrs now default off on Windows, which has no equivalent; the fallback that
writes separate files already existed. github.com/pkg/xattr needs no build tag
since it ships xattr_unsupported.go.

toExitError uses ExitError.ExitCode() rather than casting Sys() to a
syscall.WaitStatus. Equivalent on Unix, including the -1 for a signalled
process, and portable. The comment it replaces conceded there wasn't a good
way to do this.

Linux behaviour is unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Also corrects two things the design docs got wrong. isExecutable's 0111 check
is only reachable on the FreeBSD code path, so it needed no Windows work at
all; the real gap was core.LookPath, which neither split PATH correctly nor
knew about PATHEXT. And fs.SplitPathList was promoted during M1 rather than
M2, because LookPath needed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
The previous two commits were only ever checked with go build directly, which
does not exercise the BUILD files at all. Going through plz found three
problems.

golang.org/x/sys/windows cannot be depended on unconditionally. go_repo
generates the subrepo's BUILD files using the host build context, so on Linux
no target is produced for that package at all -- the sources are extracted,
but there is no BUILD file and the dependency fails to resolve. The deps are
now guarded with is_platform(os = "windows"), which is already the idiom used
in src/BUILD.plz. go_library filters srcs by build constraint, so the
_windows.go files are dropped on other platforms and the dependency genuinely
isn't needed there.

src/tool and tools/please_shim import src/process now but never declared it.

src/core's go_test had filter_srcs = False, with a comment referring to
something that no longer exists. That is harmless while a package has no
platform-specific sources and fatal once it does: the internal test compiles
the package sources alongside the test sources, so lock_other.go and
lock_windows.go were both fed to the compiler and every symbol collided.
Removing it fixes the build and all 281 tests in the package still pass.

Verified: plz build //src:please, plz build --arch windows_amd64 //src:please,
and plz test //src/... --exclude=e2e (837 tests, 835 passed, 2 skipped). The
cross-built binary parses labels and runs cold-cache builds under Wine, so
forceposix is reaching the compiler through the real BUILD path.

Note the windows_amd64 Go toolchain hash turns out not to be needed: Go
cross-compiles from the host toolchain, so there is nothing to download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
The windows_amd64 Go toolchain hash is struck off: Go cross-compiles from the
host toolchain, so there is nothing to fetch. M1's exit criterion is met, and
the M0 CI job's command already passes -- only the wiring is outstanding.

Adds two risks found by actually running plz. go_repo generates third-party
BUILD files with the host build context, so a Windows-only package like
x/sys/windows has no target on Linux and cannot be depended on
unconditionally. And verifying with go build rather than plz hides real
breakage -- one pass through plz found three bugs.

Also softens the arcat "hard gate": it did not block parsing or genrules under
Wine, so it bites later than the plan implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
PATH-style lists were split and joined on a literal ":" in six places, which
on Windows would neither parse C:\foo nor produce a list anything can read.
They now go through fs.SplitPathList and os.PathListSeparator. This is a no-op
on Unix, where the separator is ":" anyway.

src/remote/action.go is deliberately asymmetric: it splits with the local
separator, because the value was built locally, but still joins with ":",
because the worker on the other end is a POSIX machine. Same reasoning as
RemoteBashCommand. Remote execution from a Windows host is not otherwise
addressed here.

fs.ExpandHomePath read $HOME directly and its regex assumed ":" separated
PATH entries and "/" separated paths. It now uses os.UserHomeDir and builds
the pattern from the platform separators, accepting either slash on Windows.
The Unix pattern is byte-for-byte what it was.

MachineConfigFileName was /etc/please/plzconfig; on Windows it resolves under
ProgramData. DefaultPath is empty there rather than /usr/local/bin and
friends: Windows has no equivalent directory holding build tools, so there is
nothing honest to point at and users configure [build] path instead. Both
became vars rather than consts, which is why SandboxDir did too.

Build actions get USERPROFILE, TEMP and TMP alongside HOME and TMPDIR, but
only on Windows -- native tools read those, and adding them unconditionally
would change every target hash on Unix for no benefit. Verified under Wine
that all five point at the action's tmp dir.

Hash check: within a single working directory, the only targets whose hash
changes are the dependency cone of the files edited here. cmap, cli, metrics
and assets are untouched. Note that comparing hashes across two working
directories is not a valid check -- they differ for unrelated reasons.

837 tests pass; the windows_amd64 cross-build still runs builds under Wine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Comparing plz hash output between a git worktree and the main repo is not a
valid regression check: the same commit hashes differently in two working
directories, producing dozens of false positives. Stash and unstash in place
instead.

Also confirms the forward-slash normalisation is still needed -- a genrule
under Wine sees HOME with backslashes, which survives echo but will break any
command that treats backslash as an escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Build commands are shell strings, and a backslash is an escape character to
much of what runs in them. Expanding a variable is safe -- echo and printf
'%s' both round-trip a Windows path unharmed -- but passing one to anything
that interprets its arguments is not. Under Wine:

    cmd = "echo placeholder | sed -e \"s#placeholder#$TMP_DIR#\" > $OUT"

turned Z:\...\plz-out\tmp\sedtest._build into Z:<TAB>mp...sedtest._build:
\t became a literal tab and every other backslash was eaten. That is not a
hypothetical shape -- the C/C++ rules build their link line with sed, which is
the primary use case for this port.

Win32, MinGW and busybox all accept forward slashes, so the environment now
uses them throughout. Normalising here rather than at each of the twenty-odd
places a path is written means new ones cannot quietly regress it.

It runs before withUserProvidedEnv deliberately: values the user wrote
themselves are left exactly as written, since they may not be paths at all.

No-op on platforms whose separator is already a forward slash, so Linux
behaviour and Linux build environments are unchanged. The hashes that move are
exactly the dependency cone of build_env.go; cmap, cli, metrics, assets and
version do not.

The test asserts the invariant on every platform. It is trivially true on
Unix, so it was checked by neutering the normalisation and confirming it fails
under Wine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
The original note was right that this was the highest-risk detail but wrong
about the mechanism. Shell variable expansion does not reprocess escapes, so
echo and printf round-trip a Windows path fine; it is commands that interpret
their own arguments, like sed, that destroy it. Replaces the speculation with
the measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
…lege

glob() returned nothing at all on Windows, which is fatal for a build system
whose BUILD files are full of it -- including this repo's own.

The cause is a separator mismatch. The walk goes through io/fs, whose paths are
always slash-separated whatever the host, but patternToMatcher built the
pattern with filepath.Join, so on Windows it produced globdir\*.txt and matched
nothing against globdir/a.txt. toRegexString has the same assumption baked in,
hardcoding / in its character classes.

builtInGlob.Match had a subtler version of the same problem: filepath.Match
treats the separator as a backslash on Windows, so * would have matched across
/ once the pattern was fixed, and a single-star glob would have wrongly
recursed into subdirectories.

Both now use path rather than filepath, which is what matching io/fs paths
calls for. This is a no-op on Unix, where the two are the same. Verified under
Wine: globdir/*.txt matches two files and correctly excludes the one in a
subdirectory, and globdir/**/*.txt matches all three.

Note the raw "/" handling elsewhere in glob.go and in fs/sort.go is correct
for exactly the same reason, and was left alone.

Separately: creating a symlink on Windows needs Developer Mode or
SeCreateSymbolicLinkPrivilege, which an ordinary user does not have.
CopyOrLinkFile now falls back to copying what the link points at, warning
once, since for populating plz-out the content is what matters. And RemoveAll
clears the read-only attribute on files as well as directories there, because
that is what actually stops a delete on Windows.

838 tests pass. Hash changes are confined to the fs and core dependency cone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
The interesting finding was not on the list: glob() matched nothing at all on
Windows, because the pattern was built with filepath while the walk yields
io/fs paths, which are always slash-separated.

That also inverts two entries the design doc got wrong. The raw "/" handling
in fs/sort.go and parts of glob.go is correct precisely because io/fs paths
are always "/", so those needed no change. Whether filepath or path is right
depends on whether the value is an OS path or an io/fs path -- the opposite
call from the logging.go fix in M1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
A cc_library + cc_binary + cc_shared_object triple now cross-builds from Linux
to lib.a, prog.exe and libshared.dll, and prog.exe runs under Wine and links
the static library correctly. The same targets still produce prog and
libshared.so on Linux, and all 12 of cc-rules' own tests pass there.

D1 is confirmed rather than assumed. Two MinGW toolchains -- WinLibs 16.2.0
under Wine and Ubuntu's 13 cross-compiler -- both match please_cc's existing
GCC and GNU ld matchers, and the Clang matcher correctly does not, so no new
matchers are needed. Ubuntu reports "13-win32", giving a bare "13", which
MustParseVersion and Compare both handle.

Three things the experiments taught that the design did not anticipate:

- Module-level CONFIG does not see the target architecture. Defining the
  suffix as a constant silently had no effect, because these build defs are
  subincluded and CONFIG.OS there reflects the host. It has to be a function.
- A repeatable config key cannot be cleared by assigning empty:
  "defaultldflags =" yields [""], which becomes a bare -Wl, and the linker
  fails with "cannot find : Invalid argument".
- -lpthread is fine on MinGW; only -ldl had to go.

The changes live in another repo, so they are recorded as a patch under
probe/ with instructions to reproduce, until they can be upstreamed.

Still open: please_cc has no windows_amd64 release (it is fetched prebuilt per
platform), and UnitTest++ needs its Win32 sources, which blocks cc_test but
not cc_library or cc_binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Upgrades the severity of the arcat item and explains what the tool actually
does, having tripped over it trying to run the M6 end-to-end test.

arcat is Please's built-in archive toolkit -- extract, tar, and ar -- so rules
never depend on the host having tar, zip, ar or unzip. The reason it matters
more than "a hash to add" is that plugin_repo extracts the plugin zip with it,
and every language plugin is delivered that way. Without it Windows cannot
load the cc rules at all, and cc_library then needs it again for .a archives.
Parsing and simple genrules work without it, which is why the first assessment
understated it.

The port itself is easy. arcat is six Go files with no syscall, x/sys/unix or
cgo usage; it cross-compiles to PE32+, and under Wine both critical paths work
-- arcat x extracts a zip, and arcat ar -r produces an archive that MinGW
links into a working exe. The work is publishing a windows_amd64 release and
recording its hash, not porting code.

One unrelated snag found on the way: arcat's go.mod says go 1.17 while the
code uses generics, so it fails to build on any platform with a modern
toolchain. One-line upstream fix.

M6's headline test is blocked on that release plus Wine having no working DNS
here, so the plugin cannot be fetched from inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Two bugs found by driving a real C++ build with plz.exe under Wine. Both are
the same mistake in different places: treating an io/fs path, or a path that
may still hold backslashes, as though it were already in the host's form.

buildFileName joined the package name and BUILD file with filepath.Join and
then handed the result to iofs.Stat. io/fs paths are always slash-separated,
so on Windows it looked for a single file whose name contained a backslash and
found nothing. The effect is that no package below the top level parses at
all: //sub:target and //sub/nested:deep both fail, and so does every plugin,
since the plugin subrepo's build_defs live in a subdirectory. Only the root
package worked, because filepath.Join("", "BUILD") has no separator to get
wrong -- which is why earlier testing missed it.

toolPath prepends ./ to a bare filename so the shell runs it rather than
searching PATH, deciding via strings.Contains(path, "/"). On Windows the path
may still be backslash-separated at that point, so an absolute path looked
bare and became "./Z:/tmp/.../please_cc.exe". It now checks both separators.

Note this is the inverse of the fix in src/cli/logging.go, where filepath was
the right answer. Which one is correct depends on whether the value is an OS
path or an io/fs path, and that distinction is worth checking rather than
assuming.

With these, the full chain works under Wine: plz.exe extracts the cc plugin
with arcat.exe, runs build actions through busybox, identifies the toolchain
with please_cc.exe, compiles and links with MinGW g++.exe, and the resulting
hello.exe runs and prints correctly.

838 tests pass and the windows_amd64 cross-build is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
wine plz.exe now builds a C++ binary through an entirely Windows toolchain --
arcat.exe extracts the plugin, busybox runs the build actions, please_cc.exe
identifies the compiler, MinGW g++.exe compiles and links -- and the result
runs.

Records the two bugs that had to be fixed to get there, both of which were
invisible to every earlier test. The package-lookup one is the more alarming:
no package below the top level parsed on Windows at all, and it went unnoticed
because the root package is the one case where the buggy join cannot produce a
wrong separator.

Also notes two things a Windows user will hit immediately: .plzconfig rejects
unquoted backslashes, and with DefaultPath empty on Windows nothing builds
until [build] path is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Build actions are shell strings, and every local call site hardcoded "bash".
Windows ships nothing that can run one, so [build] shell and [build] shellargs
now select the shell, defaulting per-platform: bash with --noprofile --norc on
Unix, and the bundled busybox's bash applet on Windows, which rejects those two
flags and has no startup files to suppress anyway.

BashCommand becomes a method on Executor so it carries the shell with it.
RemoteBashCommand is untouched: the remote worker is a real bash whatever we
happen to be running on, so it keeps the full flag set. The cmd cache's two
"sh -c" sites and the shell that plz build --shell opens follow the same config;
the latter was a third hardcoded shell that earlier passes missed.

Resolution matters more than it looks. A shell that is on Please's own PATH is
still left for the OS to find, exactly as before, but a name that isn't there
falls back to the build path, which has Please's install directory prepended.
Without that the bundling would be pointless, because nothing puts that
directory on a Windows user's PATH.

busybox-w64 is vendored as a remote_file with a pinned hash and installed
alongside please.exe. It is GPL-2.0, which .plzconfig rejected outright, so that
is accepted now with a note: Please execs busybox rather than linking it, so the
two are separately distributed works and the release carries the licence.

Also gated off Windows: tarball(xzip = True), since busybox's xz decompresses
only, and please_sandbox, which is built on Linux namespaces.

Verified under Wine with no configuration and nothing on the PATH: a genrule
running "cat $SRCS | sort > $OUT" builds through the bundled shell, and fails
correctly when it is moved away. Target hashes on Linux are byte-identical
before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Records the five things this milestone turned up. The one worth remembering is
that resolving the shell on $PATH alone would have made bundling it pointless:
nothing puts Please's install directory on a Windows user's PATH, so the default
would never have been found.

Also notes that the busybox bash applet form behaves identically to the renamed
bash.exe that M0 tested, which is why ShellArgs selects the applet rather than
the packaging renaming the binary and shadowing a user's real bash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Makes `plz build --arch windows_amd64 //package:release_files` produce something a
Windows user can actually unzip and run, and wires the CI job that will do it.

The release is a .zip, since Windows has no guaranteed tar, holding please.exe,
busybox.exe, build_langserver.exe and a plz.cmd shim. The shim replaces the
`ln -sf please plz` that install.sh does on Unix, because symlinks on Windows
need Developer Mode. The xz tarballs are gated off Windows and the zip stands in
for them; please_sandbox is gated off too, being Linux namespaces throughout.

The .exe suffix had to be asked for per target. go_binary names its output after
the rule, so //src:please produced a file called `please`, which cmd will not run
and LookPath will not find. The general fix belongs in the go plugin.

Self-update needed two changes beyond that. Symlinking is replaced by a
per-platform linkFile: Windows hard-links, which needs no privilege on NTFS. And
because a running executable can be neither deleted nor written over, and the
file being replaced is usually the Please doing the replacing, a file that cannot
be removed is renamed aside to .stale, which the next run sweeps up.

pleasew.ps1 is the PowerShell counterpart of pleasew, written to a repo by
plz init alongside it - a repo is often worked on from more than one platform, so
picking by host would leave a Linux developer no way to set one up for their
Windows colleagues. There is no PowerShell on the Linux host, so it has been
reviewed but not executed anywhere yet.

Verified by extracting the zip as a user would and running plz.cmd under Wine: it
builds a genrule with no configuration at all, the shim finding please.exe and
please.exe finding busybox.exe beside it. `query alltargets //...` works too,
which is the forceposix smoke test.

Still outstanding for the milestone: arcat has no windows_amd64 release, so no
plugin can load there yet, and the builder image needs pushing before the CI job
can run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Everything in the release pipeline is built and wired; what is left is a
dependency on another repo. arcat has no windows_amd64 release, and since every
language plugin is delivered as a zip that arcat extracts, no plugin can load on
Windows until there is one. That is now the single thing between here and the
exit criterion.

Records that the .exe suffix had to be asked for per target rather than coming
from the go plugin, and that plz init now writes both wrapper scripts on every
platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Running the unit tests under Wine for the first time turned up four bugs that
share a cause: code that operates on repo-relative or label-derived paths was
using filepath, whose separator on Windows is a backslash. Every one is a
correctness bug, and none of them shows up on Linux, where the two are the same.

Globs crossed package boundaries. isBuildFile called filepath.Base on a path
that came from io/fs, which is always slash-separated, so on Windows it compared
the whole path against "BUILD" and never matched. No subpackage was ever
detected, and a glob in one package would happily swallow files belonging to
another.

The initial package was wrong whenever plz ran from a subdirectory. getRepoRoot
returned it with backslashes, which are illegal in a package name, so the label
failed validation and Please walked up until something parsed - usually the repo
root. Relative labels like path/to:thingy failed outright for the same reason.

$(location), $(exe), $(worker) and tool paths expanded with backslashes. These
go straight into a shell command, where a backslash is an escape character; the
design notes measured sed silently turning \t into a tab. The environment was
already normalised, but these are not environment values.

The last one changes a decision rather than fixing a slip. plz-out paths are now
built with path rather than filepath, so they are slash-separated everywhere.
The design doc argued for normalising only at the environment boundary on the
grounds it was the smaller change; that turned out to leave the replacements
above broken, and the tests already assumed slashes throughout. Win32 accepts
either separator, so nothing is given up. This is a no-op on Unix.

Also makes the tests that were asserting Unix semantics say what they mean:
home paths through os.UserHomeDir rather than $HOME, path lists split on
os.PathListSeparator rather than a colon, and LookPath looking for a tool the
test wrote itself rather than the bash the host is assumed to have. TestSymlink
skips on Windows, where creating one needs Developer Mode and Wine reports
success while producing a link it can't stat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Until now everything about the port's runtime behaviour was checked by hand.
This makes it a test target: wine_go_test runs a Go test binary cross-built for
Windows, and wine_plz_test runs the cross-built please.exe against a small repo
laid out the way the release is, with busybox.exe beside it and nothing on the
PATH.

Four targets to start: the src/core and src/fs unit tests, the shell smoke test
from the design notes - a build action with a pipe and a redirect, so the
bundled shell has to work - and a query, because a dropped forceposix tag breaks
every build label and is invisible in Please's own source.

Finding them at all needed one thing recorded here rather than fixed: Go's exec
package on Windows will not run a file whose name has no extension in PATHEXT,
even when handed its full path. The go plugin names test binaries after the
rule, so the macro copies each one to a .exe before running it. Without that,
any test whose subject re-execs itself fails obscurely.

They are labelled wine and excluded from the other passes, because building them
means cross-compiling the Go standard library for another platform - too much to
impose on someone who wanted the unit tests. test.sh runs them as a third pass
where wine is installed and says so where it isn't, and the CI job is blocking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Running the unit tests under Wine properly for the first time found four
correctness bugs, all the same shape: filepath used on paths that are
slash-separated by definition. M2 recorded the inverse of that lesson and fixed
the producers; these were the consumers it missed.

The decision that changed: normalising path separators only at the environment
boundary, which 02-shell-and-build-actions.md chose on the grounds it was the
smaller change. It isn't, because $(location) and friends are not environment
values. plz-out paths are slash-separated throughout now.

Also corrects the testing strategy's prediction about symlinks under Wine. It
assumed Wine grants the privilege unconditionally so the copy fallback goes
untested. Wine actually reports success and produces a link it cannot stat, so
Wine tells us nothing either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Setting [sandbox] build or test on Windows used to produce "Can't find sandbox
tool please_sandbox on the path", which invites you to install something that
does not exist and cannot. The defaults were already false, so the milestone's
real work was this message.

Please now says sandboxing is not implemented on the platform and that actions
will run without isolation, and builds an executor that does not claim to
sandbox rather than one that silently doesn't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
The event loop compares the paths it recorded against the ones fsnotify reports
back. Ours are slash-separated, coming from build labels; fsnotify on Windows
reports backslashes. Nothing ever matched, so every event was discarded as
belonging to a file we weren't watching and the watch simply never fired.

It fails silently, because a discarded event looks exactly like an unrelated
file changing, and it is logged at a level nobody runs at. Both sides go through
watchKey now.

The design notes had this down as documenting fsnotify's Windows limits. It
isn't a limit, it's a bug on our side, and the test that guards it only means
anything when run on Windows, so it goes in the Wine job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
translateOS needed nothing - windows already passes through the default branch,
so that is recorded rather than changed. The src/watch item was down as
documenting fsnotify's Windows limits and was a silent bug on our side instead.

Also notes that the go plugin's .exe naming now blocks more than it looked like:
plz run fails on any go_binary until it lands, because Go's exec on Windows will
not run a file with no PATHEXT extension even given its full path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Four more of the same shape as the last batch, all found by pointing the Wine
job at more packages.

output_dirs produced doubled paths. copyOutDir strips the temp directory off a
path to get an output name, comparing a filepath.Join result against a
slash-separated TmpDir. Neither prefix matched on Windows, so the whole path
survived as the output name and moveOutputs then joined the temp directory onto
a path that already contained it.

JS coverage file names were never sanitised, because the paths from the coverage
file were compared against filepath.Dir of a plz-out directory. Coverage came
out attributed to absolute build paths rather than source files. Coverage by
directory had backslashed keys for the same reason, which neither read correctly
nor matched anything configured.

file:// URLs could not name a Windows path at all. RFC 8089 puts a slash before
the drive letter, so file:///C:/foo arrives as /C:/foo, which filepath.IsAbs
rejects. No remote_file with a local URL could work.

Two tests also had to stop writing to the real home directory. They left a
read-only file at ~/secret, which on Windows the next run cannot replace; they
now point the home directory at somewhere they own. The tests that need working
symlinks or Unix permission bits skip on Windows and say why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
//src/build, //src/test, //src/cli and //src/watch join //src/core and //src/fs:
428 tests, 424 passing and 4 skipped. //src/build is the valuable one, since it
runs real build actions and so covers the process layer and the bundled shell as
well as whatever it is nominally about. wine_go_test grows a needs_shell option
that puts busybox next to the test binary, the way an install has it.

Two things about the harness itself, both found by tests failing for reasons
that had nothing to do with Please. Wine ships a hosts file with the localhost
line commented out, so anything resolving it hangs until it gives up - three
tests were each burning fifteen seconds. And ~ resolves inside the shared
prefix, so anything a test writes there leaks into the next run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkSQhgMeCVmuWqbGAP7oVM
Peter Neiss and others added 28 commits September 11, 2026 20:44
The only test that covers what a Windows user actually gets. It extracts the real
artifact rather than assembling an install out of its parts, because what is under
test is what the artifact carries.

One sh_binary exercises the whole chain: the file:// plugin template, the bundled
arcat unpacking the archive, the plugin's build defs parsing, and busybox running
the action. The repo it builds in asks for its plugin the ordinary way, with the
revision the plugin is released as rather than the one that is bundled, so the
thing being tested is only where the plugin came from.

The negative control is what makes this rigorous rather than suggestive. With the
network gone, taking the archive away has to break the build; if it does not, the
archive was never read and the passing test meant nothing.

The network is denied with a proxy pointed at a closed port, which proves there
was no HTTP egress rather than no egress at all. That is the right scope, since
fetching a plugin is an HTTP fetch, and it is what there is: Wine aborts outright
inside a user namespace, so unshare is not available to make it airtight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
08-offline-release.md is the design and the record of building it: why the plugin
archives are generated rather than committed, why they cannot come from a build
rule, why the payload has to be flat files, and what the filenames carrying no
revision costs. Marked implemented, with the two things that are not done - a
python tier for the offline test, and an airtight network denial, which Wine
cannot give us.

Two traps join the standing list. plz-out/pkg is never refreshed once it exists,
because the hlink label goes through LinkIfNotExists and the destination is named
after the version, so rebuilding a release at the same version silently leaves the
previous bytes there. And running an sh_binary in place complains on Windows,
because its payload unpacks over the read-only build outputs it was made from.

M4 no longer waits on a published arcat, since arcat is built from source now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Windows has no installer, no package manager and no post-install script, so the
instructions travel in the archive. Extract, put the directory on PATH, run plz.

It covers the things that are specific to this platform rather than repeating the
manual: that plz.cmd stands in for a symlink because those need Developer Mode,
that the files have to stay together because Please finds its shell, its archive
tool and any bundled plugins beside its own binary, and that language toolchains
are not bundled and never are.

The limitations are the ones a user hits first, all measured here: plz update
fetches only the binary and leaves everything else at the version first installed;
a repo pinning an exact [please] version tries to download a Windows release that
does not exist yet, which is a hang and then a hard failure; sandboxing is off;
antivirus locks freshly written files; and output paths nest deeply enough to want
long-path support.

release_shape_test asserts the zip holds exactly the expected members and that each
plugin archive really is the single top-level directory with a .plzconfig that
plugin_repo expects. It runs on Linux without Wine, so it catches a tool that lost
its .exe, or a file quietly dropped from the release, without waiting for a machine
to run them on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
//test/windows already cross-builds twenty-odd Go test binaries and runs them
under Wine. Those same binaries can run natively, which turns "passes under Wine"
into evidence from Windows for almost no new test code. This packages them.

The list of tests now has one definition, because it is consumed twice: by the
wine_go_test comprehension and by the bundle. Target names are unchanged, so
CircleCI and --include=wine are untouched.

Each test gets its own directory rather than sharing one tree. The data is a few
hundred megabytes, so copying it per test on the runner to isolate them would be
most of a gigabyte of IO; the tests have to be isolated, because running these
binaries in a shared tree once deleted the whole of test/; and several of them
want the harness directory itself rather than only their data.

The data lands where the test expects it for free. An architecture subrepo has an
empty root and package root, so ///windows_amd64//src/fs:test_data stages at
src/fs/test_data, which is the literal path the test opens.

bundle_smoke_test runs one entry out of the assembled bundle, under Wine, from the
bundle's own layout. It is the only check on the packaging that does not need a
Windows machine, and packaging is the part most likely to be quietly wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
run_native_tests.ps1 is wine_go_test without Wine: the test's own directory as the
working directory, $DATA from the file the bundle records beside each binary, and
the bundled busybox on the PATH where the marker says the test runs build actions.

The environment it sets mirrors core.TestEnvironment, with forward slashes,
because Please rewrites every backslash in every environment value on Windows.
Getting that wrong produces failures that look like port bugs and are not. It also
redirects LOCALAPPDATA, which Please does not: without it a test that runs a build
shares the machine's directory cache, and a cached artifact from another run is
exactly how this port has produced a false pass before.

It parses -test.v itself and writes a table to the step summary rather than
pulling in a third-party action for cosmetics. src/test/go_results.go is the right
parser and has no standalone entry point; that is where a plz test-shaped native
runner would eventually go.

known_failures.txt is empty on purpose. The first native run is what fills it in,
and a test listed there that starts passing fails the job too, so the list only
ever shrinks.

run_native_probes.ps1 covers the release as an artifact and the two Wine-invisible
classes that are reachable from a headless runner: files held open on teardown,
probed by building and cleaning repeatedly under a live virus scanner, and path
length. The interesting long-path failure is not in Please, since Go prefixes
absolute paths with \\?\ by itself, but in what Please hands busybox as a command
line - which is exactly the pipe-and-redirect action the fixture builds.

Case-insensitivity and the symlink copy fallback are deliberately not here. They
belong in Go tests in src/fs, where they ride the bundle and are written in the
language the fix will be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
The first thing anywhere that runs this port on a real Windows machine. Two jobs:
a Linux one that cross-builds the test bundle and the release, and a windows-latest
one that runs them.

It cross-builds its own artifacts rather than taking CircleCI's, because a
CircleCI workspace is scoped to one CircleCI run and cannot be read from here.
That duplication is the price of CircleCI having no Windows runner in this config.
In exchange every pull request now produces a downloadable Windows build.

It builds this repo's Please first and uses that for everything after. The
released one predates the parse-deadlock fix and hangs parsing //test/windows,
silently and for ever - the same two-step test.sh insists on.

The Windows job is advisory to begin with. The first run's job is to produce a
failure list, and a check that goes red before anyone has read it teaches people
to ignore it. Once that list is in known_failures.txt the flag comes off and the
job blocks, because an advisory Windows job is worse than none: the port's whole
problem is that nobody is thinking about Windows.

It prints LongPathsEnabled and SeCreateSymbolicLinkPrivilege before running
anything. Both change what is reachable, both differ between runner images, and
guessing either has already cost time here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Any plz command run on Windows outside a repo hung at 100% CPU instead of saying
it could not find a root. The walk up towards the filesystem root stopped when the
directory became an empty string, and on Windows it never does: trimming the
separator off "C:\" leaves "C:", and splitting that returns it unchanged, because
the volume name is the whole path. One stat per iteration, for ever.

It now stops when the walk stops going anywhere, which is also correct for UNC
roots and needs no build tag.

This is what hung exec_test on the first native run - the only test in that package
that calls MustFindRepoRoot - and it is the one finding from that run that nobody
had predicted. Wine never showed it because the harness happens to run those
binaries inside this repo, where the walk finds a .plzconfig after a few levels.

The walk is split out so it can be tested from a given directory rather than only
from the working one. The termination test fails rather than hanging, since
hanging is exactly what it is guarding against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
plz clean failed every time on Windows, in both of its modes: the background
rename of plz-out got Access denied, and the synchronous fallback got "the process
cannot access the file because it is being used by another process" on
plz-out/log/build.log.

That file is Please's own. --log_file defaults to a path under plz-out, and
InitFileLogging keeps the handle in a package global for the life of the process;
its only close was behind AtExit, which runs on a terminating signal and never on
an ordinary exit. So clean asked Windows to delete a directory containing a file it
was holding open. The detached child that does the deletion already avoids opening
a log at all, for this reason; the parent now closes its own first.

RemoveAll also handles the case properly rather than reporting it as a permissions
problem. It used to run a chmod walk over the whole tree - leaving every build
output writable - and then fail again for the same reason, since Go maps a sharing
violation to ErrPermission. It now recognises one, retries briefly because virus
scanners hold freshly written files open for a moment, and says what the problem
actually is when the handle is not going away. The risk register said to design
RemoveAll defensively for this and it had not been done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
The guard that stops a remote_file reaching inside the repo never fired on
Windows. It compared the path out of a file:// URL, which is slash-separated,
against core.RepoRoot, which is in the OS's own separator and so backslashed
there. A plain HasPrefix between the two never matches.

The test did not catch it under Wine either. TMP_DIR is a Linux path there, so
the earlier absolute-path check failed first and the guard was never reached. It
has never been exercised until a real Windows machine ran it.

The same comparison appeared twice more, both on paths that arrive from outside
and so may be spelled either way: the coverage file list, and the filenames in a
coverage report written by another tool. All three now go through one helper that
normalises both sides, and that only matches at a path boundary, so /repo is not a
prefix of /repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
plz run appended a second PATH rather than replacing the one it found. Windows
stores the variable as Path, and addOneEnv compared names exactly, so "PATH="
matched nothing. os/exec papers over it by deduplicating case-insensitively and
keeping the last entry, so the child process was fine, but everything else that
reads that slice saw both - ExecReplace and the audit log among them.

Names are now compared the way the platform does, and the entry keeps the OS's own
spelling rather than ours.

The test was wrong in the same place and in a way that hid this: it asserted the
literal string "PATH=<value>" against os.Environ(). It now looks the variable up
by name and asserts there is exactly one of it, which is the part that actually
guards against appending a duplicate.

TestCheckSecrets used /bin/sh as a file that definitely exists. Windows does not
have one, and Wine passed it only because its Z: drive maps the host's root, so
the assertion proved nothing there. It makes a file now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Several of the bundled tests call MustFindRepoRoot. Under Wine they get a root by
accident, because the harness runs them inside this repo. Natively the bundle sits
wherever the runner unpacked it with nothing above it, so they would fail on a
missing root rather than on whatever they are about. The bundle now carries a
.plzconfig, which is the same thing Wine was giving them for free.

The Windows job was advisory for exactly one run, to produce a failure list without
a red check nobody had read yet. Everything that run found is fixed, so the flag
comes off. Anything genuinely left over belongs in known_failures.txt with a
reason, not behind a flag that makes the whole job ignorable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
upload-artifact drops hidden files unless told not to, so the .plzconfig that
marks the bundle as a repo never reached the Windows runner. The tests that call
MustFindRepoRoot found no root and died.

Nothing on the Linux side could have caught this: the Wine test runs the bundle
straight out of plz-out, so it never passes through an artifact at all. It only
shows up in the round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
05-testing-strategy.md's "What Wine does not cover" was a list of predictions. It
now carries a table of measured results with a date against each one. The
predictions were mostly right: the sharing violation materialised and broke plz
clean outright, on Please's own log file. Long paths did not reproduce, because
the runner has them enabled. The symlink privilege is disabled there, so the copy
fallback has been exercised on every run since.

The list also missed one, and it was the worst of the five: every plz run outside
a repo hung at 100% CPU for ever. Nothing predicted it because nothing had ever
run plz outside a repo on Windows.

Three new standing traps, all of which cost time here: filepath.Split does not
terminate a walk at a drive root; a handle this process holds is still a handle,
and the log file lives inside the directory clean deletes; and upload-artifact
drops hidden files, which no Linux-side test can catch because the Wine tests
never pass through an artifact.

M9's first two items tick. Console and Ctrl-C stay open and are now explicitly out
of reach from a CI step rather than merely undone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
M5's last open item. The cc plugin can now build its own targets with the
configured toolchain, so this is the proof: a cc_test linked against the same
cc_shared_object the DLL test uses, cross-built and run under Wine. It passes.

The MinGW C++ runtime travels beside it. Those DLLs cannot simply be linked in:
-static-libstdc++ and -static-libgcc are driver flags, the plugin wraps
linker_flags in -Wl, so ld gets them and rejects them, and a target's
compiler_flags reach the compile step but not the link. They come from the
compiler itself rather than being pinned, because they have to match the compiler
that built the binary.

A cc_binary doing less C++ never needed them, which is why the DLL test did not
find this.

The assertion is the exit code. UnitTest++ writes XML to test.results and prints
nothing when everything passes, returning the number of failures; a binary that
cannot start at all exits 53.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Both were recorded as fact and both were wrong, which is worse than being unknown
because they stopped anyone looking.

cc_test was blocked on UnitTest++ needing its Win32 sources, said three files. The
plugin has selected them since before this port began. The real cause was that the
plugin's own targets compiled with the host toolchain, and finding that took ten
minutes once someone actually ran the build instead of reading the note. M5 is
done.

exec_test was listed as unreached past TestCommandMountNotSandboxed. That was true
before the repo-root fix and stopped being true when it landed; all ten of its
tests pass natively.

The pick-up list is re-ordered around what the native run actually shows: three
symlink tests that skip on the one machine where the answer is interesting, two
plz run tests that skip because their fixtures are shebang scripts, and a
Ctrl-Break path that is exercised but never distinguished from the job object
killing everything anyway.

Also corrects two stale facts: the branch is merged, and the plugin branches now
live in forks rather than only on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Every link: label a build declares was quietly turning into a warning on Windows.
Creating a symlink there needs Developer Mode or SeCreateSymbolicLinkPrivilege,
an ordinary user has neither, and buildLinks passed os.Symlink straight through to
a helper that logs the failure and carries on. So plz-out/please and plz-out/go/src
simply did not appear, with nothing louder than a warning to say why.

They now fall back to a copy, which is the same trade CopyOrLinkFile already
makes: for populating plz-out the content is what matters, not that the link is
reproduced.

The three tests that cover this skipped on all of Windows, so they skipped on the
one machine where the answer is interesting - the CI runner has the privilege
disabled, which is the case most users are in and the case the fallback exists
for. They now skip only under Wine, where os.Symlink reports success and produces
a link os.Lstat cannot find, so asserting against it proves nothing either way.

fs.IsWine is how they tell, via a function only Wine exports, which is the way
Wine documents for this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
TestSequential and TestParallel skipped on Windows because their fixtures are two
shell scripts relying on a #! line, and Windows has no such mechanism. That was
the whole of the plz run coverage gap: the code was never in question, only the
fixtures.

They now have .cmd siblings, which is the same answer the shell plugin reaches for
an sh_binary on Windows - a batch file is the only script the platform runs by
name. Both tests pass under Wine instead of skipping, and will run natively.

.gitattributes stops any of this being translated on checkout. cmd.exe is not
reliable on LF-only input, and the fixtures under test/windows are compared byte
for byte against output the bundled busybox wrote, which does no translation
either. The Windows CI job sets core.autocrlf input for the same reason; this
makes it hold for anyone who clones without that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
plz run on an sh_binary failed on Windows with

    'plz-out' is not recognized as an internal or external command

An sh_binary is a .cmd there, a .cmd runs through cmd.exe, and cmd.exe reads a
forward slash as the start of a switch - so plz-out/bin/x.cmd is the command
"plz-out" with two switches after it. Please builds that path slash-separated, as
it should everywhere else.

Only the executable's own path is converted, at the point it is handed to the OS.

Wine's cmd parses it happily, which is why this survived every Wine run and
appeared the moment the two tests behind it stopped being skipped. It is the
second time this port has found a bug by deleting a skip rather than by adding a
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
809 tests now run on a real Windows machine, up from 800, and the two that still
skip there are skipped on every platform and always were.

Getting from seven skips to two found two real failures that Wine had been passing
for months. plz run handed cmd.exe a forward-slashed path, which it reads as a
switch, so it could not launch an sh_binary at all. And every link: label silently
became a warning, because buildLinks passed os.Symlink straight through while
CopyOrLinkFile had had a fallback all along.

That is now a standing note in its own right: a skip hides a bug better than a
missing test does. Both of these sat behind runtime.GOOS == "windows" skips that
looked perfectly reasonable when they were written.

Ctrl-Break stays open, with the reason it is harder than it looks: KillProcess
gives the graceful path 30ms before terminating the job regardless, so a test
asserting graceful shutdown races that timer on a CI machine, and a flaky test in
a blocking job is worse than no test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
The Windows fixes for all four plugins are on GitHub now, so the machinery that
existed only because they were not can go. That was more than a pin: a
local-checkout switch in plugins/BUILD, a vendoring script, a gitignored archive
directory, a bundled payload in the release, a file:// plugin repo default in the
binary, and eight tests that only existed when a checkout was configured.

Pinned to commit SHAs rather than to the windows branch. A branch archive changes
whenever it is pushed to, which would silently move every build hash that reaches
it and leave the cache serving something else.

The gain is larger than the deletion. Those eight tests are now unconditional -
both pex tests, the DLL test, the cc_test, the sh_binary test - so they run in CI
on every change instead of only on the one machine that had the checkouts. The two
//test/export failures go too: they only ever failed because .plzconfig.local was
present, which it no longer needs to be.

Four workarounds this repo carried because the old pins lacked the fixes are gone
with it: the out = "please.exe" overrides in src/BUILD.plz and
//tools/build_langserver, and the cc toolchain and defaultldflags lines in
.plzconfig_windows_amd64. The plugins work all of that out themselves now, so
keeping them would be second-guessing a plugin that is right.

PexTool stays. It builds please_pex from the plugin's source because no published
release carries the Windows preamble, and the fork publishes no releases at all.

arcat stays in the release too, and has to: it is built in-tree from the module
proxy, and without it a Windows plz cannot extract a downloaded plugin. What goes
with the bundling is please_go.exe, please_cc.exe and please_pex.exe, which were
built inside the checkouts and cannot be built from here - cross-compiling a
plugin's own tool collides on subrepo names. So a native Windows plz can fetch and
parse plugins but not build a Go, C++ or Python target until those three have
releases to download. Cross-building from Linux is unaffected. That is now the
third item in what to pick up next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
A native Windows plz could not build a Go, C++ or Python target, because
please_go, please_cc and please_pex have no windows_amd64 release to fetch and
cannot be built from the repo using the plugin - cross-compiling a plugin's own
tool collides on subrepo names. Please's release carried copies for a while, but
that only helped people who had that release.

All three are now published from the forks and downloaded like any other platform.
Only windows_amd64 is redirected there; everything else still comes from
please-build, so when upstream publishes its own the redirect goes away and
nothing else changes. Verified by fetching each asset and checking the hash the
BUILD file pins.

Each download needs an explicit out on Windows: the asset name carries the version
and platform, which leaves it with no extension in PATHEXT, and Go's exec will not
run such a file even when handed its full path.

PexTool stays in .plzconfig_windows_amd64 and now has a narrower reason. The
Windows please_pex has the preamble, but a Linux host cross-building uses the
Linux one, and that is still the upstream build without it. Publishing a Linux
please_pex from the fork is what would retire the override; it is now the third
thing to pick up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
The last of M9. Everything technical was done; what was left was the part that
decides whether anyone outside this repo can use it.

docs/faq.html said Windows was not supported natively, which had been the honest
answer for years and stopped being true. It now says what is supported and names
the two things that genuinely behave differently: there is no build sandbox,
because Please's is built on Linux namespaces and Windows has no equivalent, and
real-time virus scanning holds freshly written files open, which Windows treats as
a reason to refuse deleting them.

get_plz.ps1 is the counterpart to get_plz.sh, served and signed from the same
bucket by the same release script, and run with irm ... | iex since Windows has no
shell to curl into. It is deliberately parallel to the sh version; the differences
are all forced by the platform - a .zip rather than a tarball, a plz.cmd shim
rather than a symlink, and hard-links rather than symlinks to link the install up
a level.

The changelog entry leads with the feature and then lists the bugs a real Windows
machine found, because those are what someone upgrading will recognise: plz clean
failing every time, plz hanging outside a repo, link: labels silently doing
nothing.

Also corrects two statements in the state of play that had gone stale within the
hour: plz run on an sh_binary is fixed and tested rather than open, and nothing is
blocked on push access any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
.plzconfig_windows_amd64 pointed PexTool at the plugin's own source, because the
released Linux please_pex has no Windows preamble and a Linux host cross-compiling
uses the Linux tool. A .pex is a zip with an executable stub in front, so what came
out was an ELF-prefixed file Windows would not run.

The fork now publishes a Linux please_pex that carries the preamble, so the
override is gone and the pex tests pass without it.

That leaves .plzconfig_windows_amd64 with nothing but genuine platform facts: the
forceposix build tag, because go-flags reads / as an option delimiter and would
break every build label; no extended attributes; and no sandbox. Every entry that
was compensating for a plugin has now been fixed in the plugin instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
The Windows support was real and unreachable at the same time. Upstream publishes
to a GCS bucket from a CircleCI job that only runs on thought-machine/please, so
nothing this branch produced could be downloaded by pleasew.ps1, get_plz.ps1 or
plz update. A working port nobody can install is not a working port.

The release workflow builds the same artifacts and publishes them as a GitHub
Release on this fork. The asset names carry the platform, which is what
gen_release.py already does for the GitHub half of an upstream release, so the two
agree on names even though they disagree on paths.

Both installers now understand either layout and pick by looking at the base URL:
a release keeps everything under one tag with the platform in the filename, the
bucket keeps a directory per platform and version. Setting [please]
downloadlocation, or PLZ_DOWNLOAD_BASE for get_plz.ps1, switches back to the
bucket. That is what makes this revertible in one line if upstream ever starts
publishing Windows builds.

Numbered 18.0.0 rather than 17.34.0. Native support for a new operating system is
not a patch, and this fork's numbering is its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnBWtZhT4ReP23CYYbfkRa
Nothing anywhere had ever executed a line of the codelabs at
https://please.build/codelabs.html, on any platform. This adds a blocking
codelabs job to the Windows workflow that replays all eight on windows-latest
with the release zip, the way a reader would.

The steps are extracted from docs/codelabs/*.md rather than transcribed, so the
check cannot drift from the published pages. A block no rule can classify is an
error, and //test/windows/codelab_script/script:script_test checks that against
the real codelabs on Linux. What the Markdown cannot say lives in
test/windows/codelab_steps.conf, each stanza with a reason and pinned to the
text it was decided about. No codelab is edited.

Four failures are listed ahead of the first run, from facts checked directly:
plz init plugin writes upstream plugin_repo targets whose please_go and
please_pex have no windows_amd64 release, Puku has none either, and a bash
environment prefix is not PowerShell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djqj7h7hQ1Y9Gj99s4DLm9
The first native run showed the harness manufacturing a failure. plz init
plugin go already writes GoTool, the Go codelab's fragment sets it again, and
appending the fragment verbatim left a plugin section with a repeated key,
which Please refuses. A reader edits the key instead, so the runner now does
too: a key the section has is replaced, a new key joins its section, and a new
section is appended. Subsection names stay case-sensitive.

Also from that run: a transcript's output is attached to the command it
follows rather than to the block's last command, and a failing command's
errors are plain text rather than CLIXML.

Two genuine Windows failures join the known list. genrule's plz run of a
#!/bin/bash tool fails because Windows runs nothing by shebang, and plz_query's
cloned repo has no please_go on Windows and asks golang.org for a Go 1.20
.tar.gz that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djqj7h7hQ1Y9Gj99s4DLm9
With fragments merged key by key, the second run left one failure nobody had
listed, and it is not a Windows failure. plz init plugin go generates a
go_stdlib in third_party/go/BUILD and points STDLib at it; the Go codelabs'
own third_party/go/BUILD holds only a go_toolchain, so following them drops the
stdlib and every Go build fails to find //third_party/go:std. The Kubernetes
codelab stops there, and go_intro hits it ahead of its Windows failures.

The state of play now says what the runs found: only using_plugins can be
followed to its end on Windows, and every entry in the known-failures list
carries the log line behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Djqj7h7hQ1Y9Gj99s4DLm9
Replay the codelabs on Windows, and record what they cannot do
@PeterNeiss

Copy link
Copy Markdown
Author

My AI is doing something wrong, sorry.

@PeterNeiss PeterNeiss closed this Sep 13, 2026
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