Skip to content

feat: Windows and macOS Intel wheels and CLI, with Windows CI - #181

Open
kvlonge wants to merge 12 commits into
mainfrom
feat/windows-wheels
Open

kvlonge wants to merge 12 commits into
mainfrom
feat/windows-wheels

Conversation

@kvlonge

@kvlonge kvlonge commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

CHI-1235. Adds Windows x86_64 and macOS x86_64 (Intel) wheels for sqlload and sqlload-cli, and tests Windows in CI.

Why Windows was never built

It was left out, not decided against: publish.yml has built the same three wheels since the first commit, and no commit ever added or removed Windows. Since then, two things stopped the crate building there:

Changes

  • Wheels: windows-latest and macos-15-intel rows. macos-15-intel is GitHub's last Intel image, available until August 2027. macOS wheels target 11.0, which was already the arm64 floor. Vendored OpenSSL builds with Strawberry Perl on Windows. The smoke test now installs and runs both wheels on every platform.

  • Publish on pull requests: a PR that changes packaging (this workflow, the tag checks, the pyprojects, the workspace and crate manifests, Cargo.lock) builds and smoke-tests every wheel and the sdists, and publishes nothing. PyPI and crates.io run only on a dispatched release.

  • Windows paths: these are now local folders on Windows: drive paths (C:\data), UNC shares (\\server\share\x), \\?\ and \\?\UNC\ paths, file:///C:/x, file://localhost/C:/x and file://server/share/x. Unix parsing is unchanged. Path matching under a filesystem connection compares roots too, so D:\lake is not under C:\lake. On Windows, roots and folder names match ignoring case, as NTFS and SMB do. A share on localhost (\\localhost\share) opens through \\127.0.0.1\share, the same server. object_store keeps a local root as a file:// URL, and the url crate reads file://localhost/share/... back as a drive path. Without this, every file on such a share failed with "Unable to convert URL". The Windows end-to-end job found it. It now writes to the share both as \\localhost\… and through the runner's own name, as a share on another server is named. SQLLoad's own messages keep the path as written, but object_store's error text, which SQLLoad includes, shows \\127.0.0.1. Windows keeps SMB credentials per server name, so access that depends on a net use or cmdkey entry for localhost alone may be refused through 127.0.0.1; such a share didn't work through the store at all before. \\.\UNC\localhost\… roots are still unsupported, as before. A read-only check of this fix covered other servers, \\?\UNC\ and file:// forms, localhost./IPv6/127.0.0.1 spellings and Unix, and found no blockers.

  • ssh-agent on Windows: russh reaches the agent through SSH_AUTH_SOCK's Unix socket only on Unix, so ssh_tunnel didn't compile for Windows. There it now opens the pipe SSH_AUTH_SOCK names, or else OpenSSH's agent service pipe (\\.\pipe\openssh-ssh-agent).

    • Windows ssh-agent (OpenSSH agent pipe): compiles and unit-tested, not live-tested. The unit test covers which pipe is chosen.
    • Key-file and password sign-in on Windows: the same code as on Linux and macOS, compiled for Windows, but not covered by the Windows end-to-end checks, which don't go through a bastion. The SSH tunnel docs say so.
    • The test kit sets Unix key-file permissions only on Unix.
  • Verify, new Windows job (windows-2025): runs the unit tests, then runs the executable end to end (.github/scripts/windows_e2e.py):

    • drive folder to DuckDB;
    • Parquet onto a real SMB share (\\localhost\sqlload-e2e), read back through file://localhost/... and a \\?\UNC\ path;
    • psqlODBC 18.00.0006 (x64 MSI) against the runner's Postgres 17, then an incremental merge on a fractional-second timestamp cursor;
    • a postgres_cdc load streaming through the Windows relay.

    The aggregate Verify check now includes this job.

  • Publish concurrency: a new push to a pull request cancels that PR's older Publish run. A dispatched release run has a group of its own, so releases never cancel each other.

  • Commands run on an 8 MiB thread: the Windows end-to-end copy onto the UNC share crashed with a stack overflow (0xC00000FD), because a Windows main thread has 1 MB of stack against 8 MB on Linux. Making the futures smaller didn't fix it (Box::pin was tried). run_args_with_host now runs each command on a scoped thread named sqlload-command with 8 MiB of stack, the same for the executable and the wheel. A panic on that thread is resumed in the caller. Reproduced on Linux with ulimit -s 1024 before the fix; it passes after.

The Postgres replication relay on Windows

Why there is a relay at all: pgwire-replication dials host:port itself, so its own TLS can't run through SQLLoad's SSH tunnel. The relay lets the TLS code every other SQLLoad Postgres connection uses serve both direct and tunnelled replication sessions.

Both relays now live in a session_relay module, and relay_tls calls relay_session. On Linux and macOS the relay is unchanged: a socket file in a 0700 folder. On Windows, pgwire-replication can only dial TCP (its socket path is cfg(unix)), and it can't take a stream we open ourselves. So the relay listens on an ephemeral 127.0.0.1 port:

  • Who gets through: it relays only the connection whose StartupMessage has application_name = sqlload-<32 random bytes, hex>, made fresh for each session. It compares in constant time and rewrites the name to sqlload before forwarding.
  • Everything else is refused:
    • It drops a connection with no secret or a wrong one, an SSLRequest, protocol 2, a bad length, or more than 10,000 bytes, and nothing of it reaches the database.
    • Each connection is read on its own task with a 5-second deadline, so a silent connection can't hold up the real client. A refusal no longer disables the select! arm that collects admissions. The review found that two refusals finishing together could leave the admitted client unread until the 30-second deadline.
    • The port closes once the client is admitted, or after 30 seconds.
  • Where the secret goes: never into logs, error messages or reports. It is held in Secret, whose Debug is redacted. It is never forwarded to the server.
  • Tests:
    • Unit tests for the startup check: 12 cases, including one-character-off and prefix secrets.
    • Over real loopback sockets, in five scenarios: no other connection; a silent connection first; two refused connections whose refusals finish together (this fails 3 out of 3 runs with the old select! arm); and wrong-secret, no-secret, oversize, SSLRequest, protocol 2.0 and no-application-name connections. In each, the server gets zero bytes before the client, every earlier connection is closed unanswered, the client is relayed within 2.5 seconds, the forwarded StartupMessage carries sqlload and not the secret, bytes pipe both ways, and a later connection is refused.
    • These run on Linux and on the Windows job. The Windows end-to-end job also streams a real CDC session through it.
  • Residual risk: between the replication client and the relay, the sign-in exchange and then every replicated row cross loopback in plain text. SCRAM never sends the password, but row data can be read by anyone who can capture loopback traffic. On Windows that takes an administrator, who could also read SQLLoad's memory. This is stated in the Postgres TLS docs.
  • Upstream fix: ReplicationClient::connect_on_stream / connect_gated_on_stream in pgwire-replication would remove the relay on every OS. The patch is written and passes upstream's 142 unit tests, clippy and fmt. The upstream PR isn't open yet: opening a PR on an outside repository is Kevin's decision, and it's pending. The patch, its base commit and a draft PR are on CHI-1242, the ticket for removing the relay. The upstream PR will be linked here once it's opened.

Ctrl-C during a Python-source load

Commands no longer run on Python's main thread, and Python only runs signal handlers there. Ctrl-C is unaffected, because sqlload._cli sets SIGINT to its default action before entering native code. Ctrl-C then ends the process as it ends the executable, from whichever thread is running. Table functions already ran on tokio's blocking workers.

.github/scripts/interrupt_python_load.py checks this. It runs in the wheel smoke test on all five platforms, so the release run checks it on the wheels it publishes:

  • A table function yields five rows, then blocks.
  • The load gets a real Ctrl-C: SIGINT on Linux and macOS, and a console Ctrl-C event in the load's own console on Windows.
  • The load must end within 5 seconds with SIGINT's status (STATUS_CONTROL_C_EXIT on Windows), print no traceback, and commit nothing.
  • The next load must then write every row once.

On Linux it passes: the load exits with -2 at once and nothing is committed. As a control, removing the SIG_DFL line from _cli.py makes it fail ("still running 15 s after Ctrl-C"). That is the regression it guards against. Exit code and partial-commit behaviour are the same as before this PR, because the mechanism (SIG_DFL in _cli.py) is unchanged. The Windows result is in this PR's Publish run.

Checks run locally (Linux)

  • cargo test --workspace --lib --features sqlload/odbc: 259 passed.
  • clippy in all three make check-ci configurations: clean.
  • fensu: 0 faults.
  • cargo xwin check for x86_64-pc-windows-msvc (lib, unit tests and binary, without DuckDB): clean. The Windows CI job runs the tests there.

Build sqlload and sqlload-cli wheels for Windows x86_64 (MSVC) and macOS
x86_64 (macos-15-intel), and test Windows in CI.

- librdkafka builds with CMake on Windows, where its configure script
  and make are missing.
- Local paths: drive paths (C:\data), UNC shares (\\server\share),
  \\?\ paths and file:///C:/ and file://server/share URLs are local
  folders on Windows; Unix paths are unchanged.
- A postgres_cdc replication session's relay on Windows is a 127.0.0.1
  port, since the replication client cannot reach a socket file there.
  It relays only the connection whose startup message carries a random
  per-session secret, drops every other connection before a byte
  reaches the server, gives each 5 seconds, and never forwards the
  secret.
- Verify gains a Windows job: unit tests, then the executable end to
  end over drive paths, a UNC share, psqlODBC against the runner's
  Postgres, and a postgres_cdc load streamed through the relay.
- Publish builds and smoke-tests every wheel on pull requests that
  change packaging, and publishes only when dispatched.
…nto session_relay

A relay refusal no longer disables tokio::select!'s join arm, which could
leave an admitted client unread until the relay's deadline when two
refusals finished together; a test now opens refused connections first.
The Unix socket-folder and Windows loopback relays live in session_relay.
On Windows, folder names under a filesystem connection match ignoring
case, and file://localhost/C:/ names the drive. Publish's PR trigger
also covers the workspace manifest and lockfile.
russh reaches ssh-agent through SSH_AUTH_SOCK's Unix socket only on Unix;
on Windows it now opens the pipe SSH_AUTH_SOCK names, or else the OpenSSH
agent service's. The test kit sets Unix key-file permissions only on Unix.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Deploying sqlload with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3642977
Status: ✅  Deploy successful!
Preview URL: https://6186b065.sqlload.pages.dev
Branch Preview URL: https://wip-chi-1235-windows.sqlload.pages.dev

View logs

… folder-swap gap Windows has

A Windows temp path in a TOML basic string reads \U as a unicode escape, so the
command tests now quote the root with single quotes. Windows can't swap two
folders in one rename, so a reader may briefly see no folder there, as the files
target docs now say.
…er's

A Windows main thread, sqlload.exe's or python.exe's, has 1 MB of stack, which a
debug build's copy into a files target outgrew (0xC00000FD, reproduced on Linux
with ulimit -s 1024). Every command now runs on a scoped thread with a Linux main
thread's default 8 MiB, whoever calls it.
…ested wheel

A table function yields rows and then blocks; the load gets a real Ctrl-C (SIGINT, or a console
Ctrl-C event on Windows) and must end at once as the native executable does, with no traceback and
nothing committed, and the next load must write every row once. Commands now run on a thread of
their own rather than Python's main thread, so this checks Ctrl-C still reaches them.
object_store keeps a local store's root as a file:// URL, and the url crate reads
file://localhost/share/... back as a drive path, so every file on a \\localhost\ share failed to
convert ("Unable to convert URL ... to filesystem path"). The store now opens such a share as
\\127.0.0.1\share, the same server; what SQLLoad prints keeps the path as written. The Windows
end-to-end checks also write through the machine's own name, as a share on another server is named.
…ve console does, with a control

The Windows wheel's Ctrl-C check found the load still running 15 s after the event. A plain load
installs no Ctrl-C handler of its own, and a Windows child inherits its parent's "ignore Ctrl-C"
flag, which the runner's processes may carry. Each interrupted child now clears that flag first, as
a process in an interactive console has it clear. Before the load, a plain Python process with
Ctrl-C's default action gets the same event: once as the runner starts it (printed) and once with
the flag cleared (it must end), so a failing load is the load's fault, not the check's.

This branch has not been deployed

No deployments
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