docs: define the cross-platform porting strategy (ADR 0020, macOS first)
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
CI / clang-format (push) Canceled after 0s
CI / testserver (push) Canceled after 0s
CI / bootstrap-script (push) Canceled after 0s
CI / bootstrap-script-2604 (push) Canceled after 0s
CI / extension-lint (push) Canceled after 0s
CI / build (clang) (push) Canceled after 0s
CI / build (gcc) (push) Canceled after 0s
CI / sanitizers (dev) (push) Canceled after 0s
CI / sanitizers (tsan) (push) Canceled after 0s
CI / clang-tidy (push) Canceled after 0s
CI / conformance (push) Canceled after 0s
CI / nightly-integration (push) Canceled after 0s
CI / gui-dod (push) Canceled after 0s
CI / gui-dod-nightly (push) Canceled after 0s
The tree is Ubuntu-first, but the Linux-specific surface is about ten files and already sits at the bottom of the I/O and RPC stacks. Rather than fork the repo or scatter ifdefs, platform differences go behind narrow seams with one backend directory per OS, selected centrally in cmake/platform.cmake. Linux stays the reference implementation and its behaviour does not change: the owning lanes (CORE, DAEMON) move today's Linux code behind the seams on Ubuntu first, with the suite still green, before any macOS code exists. A new PORT lane owns only the per-OS backends, so macOS work never writes shared logic and CLAUDE.md's lane rule stays intact. Adds ADR 0020 (the decision and phases), docs/08-porting.md (seam list, API mapping for macOS and Windows, dependency table, verification gates) and docs/agents/AGENT-PORT.md (the lane brief), plus the PORT row in the ownership table. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -12,6 +12,7 @@ Read this before touching anything. Then read your lane brief in [docs/agents/](
|
||||
| GUI | `gui/` | everything else |
|
||||
| EXT | `extension/` | everything else |
|
||||
| PKG/QA | `packaging/`, `.github/`, `tools/testserver/`, `tests/integration/`, `tests/e2e/`, root build files | any lane's feature code |
|
||||
| PORT | `**/platform/<os>/**`, `cmake/platform*.cmake`, `packaging/macos/`, `packaging/windows/` | all logic — see [ADR 0020](docs/adr/0020-cross-platform-strategy.md) |
|
||||
|
||||
If your task seems to require editing another lane's files, that is a signal the interface
|
||||
is wrong. **File the request; don't reach across.**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 08 — Porting guide (macOS first, Windows later)
|
||||
|
||||
Read `docs/adr/0020-cross-platform-strategy.md` first; this file is the concrete inventory
|
||||
it refers to. **Rule: Linux behaviour never changes. `#ifdef` never appears in logic.**
|
||||
|
||||
## Directory layout
|
||||
|
||||
Platform backends live beside the code they serve, one directory per OS:
|
||||
|
||||
```
|
||||
core/src/io/platform/linux/file_ops.cpp core/src/io/platform/macos/file_ops.cpp
|
||||
core/src/io/platform/file_ops.hpp # the seam — no #ifdef, no OS types
|
||||
daemon/src/rpc/platform/linux/{wakeup,peercred,instance_lock,runtime_dir}.cpp
|
||||
daemon/src/rpc/platform/macos/{...}.cpp
|
||||
daemon/src/rpc/platform/*.hpp # the seams
|
||||
cmake/platform.cmake # VELOX_OS_* + source selection
|
||||
```
|
||||
|
||||
`cmake/platform.cmake` sets exactly one of `VELOX_OS_LINUX`, `VELOX_OS_MACOS`,
|
||||
`VELOX_OS_WINDOWS`, and exposes `velox_platform_sources(<target> <dir>)` which adds
|
||||
`<dir>/platform/<os>/*.cpp`. Selection happens there and nowhere else.
|
||||
|
||||
## The seams
|
||||
|
||||
Five interfaces cover the whole port. Signatures are indicative, not binding — the owning
|
||||
lane settles them in Phase 0.
|
||||
|
||||
| Seam | Interface | Why |
|
||||
|---|---|---|
|
||||
| File preallocation | `Result<void> preallocate(int fd, uint64_t bytes)` | `posix_fallocate` is Linux/glibc |
|
||||
| Cache advice | `void advise_dontneed(int fd, uint64_t off, uint64_t len)` | `posix_fadvise` has no macOS equivalent |
|
||||
| Durable flush | `Result<void> flush_durable(int fd)` | `fdatasync` vs `F_FULLFSYNC` |
|
||||
| Loop wakeup | `class Wakeup { int pollfd(); void signal(); void drain(); }` | `eventfd` is Linux-only |
|
||||
| Peer identity | `Result<PeerId> peer_of(int fd)` | `SO_PEERCRED` vs `LOCAL_PEERCRED` |
|
||||
| Single instance | `Result<Lock> acquire(string_view runtime_dir)` | abstract sockets are Linux-only |
|
||||
| Runtime dir | `string runtime_dir()`, `string data_dir()` | XDG vs `~/Library` |
|
||||
|
||||
## API mapping
|
||||
|
||||
| Linux (today) | macOS | Windows (later) | Note |
|
||||
|---|---|---|---|
|
||||
| `posix_fallocate(fd,0,n)` | `fcntl(F_PREALLOCATE)` then `ftruncate(n)` | `SetFileValidData` / `SetEndOfFile` | macOS needs the `ftruncate`; `F_PREALLOCATE` alone does not set size. Fall back to `ftruncate` on failure, exactly as the Linux path already does for `EOPNOTSUPP` |
|
||||
| `posix_fadvise(DONTNEED)` | no equivalent — **no-op** | `FILE_FLAG_NO_BUFFERING` | Do **not** substitute `F_NOCACHE`: it changes caching for the whole descriptor, not a written range. A no-op is honest; record it |
|
||||
| `fdatasync(fd)` | `fcntl(fd, F_FULLFSYNC)`, fall back to `fsync` | `FlushFileBuffers` | `fsync` on macOS does **not** guarantee the drive flushed. `.veloxpart` resume integrity depends on this — use `F_FULLFSYNC` |
|
||||
| `pwrite` | same | `WriteFile` + `OVERLAPPED` | POSIX, no work |
|
||||
| `O_NOFOLLOW` | same | `FILE_FLAG_OPEN_REPARSE_POINT` | POSIX, no work |
|
||||
| `eventfd` | self-pipe (`pipe2`/`O_NONBLOCK|O_CLOEXEC`) | event object | `poll(2)` already used, so a pipe read-end drops straight in |
|
||||
| `timerfd` | `poll()` timeout computed from the next deadline | waitable timer | Simplest port: the loop already has a deadline set |
|
||||
| `SO_PEERCRED` + `struct ucred` | `getpeereid(fd,&uid,&gid)` | named-pipe token | Same-UID check is the security property; keep it |
|
||||
| abstract socket `\0velox-daemon-<hash>` | socket file in the runtime dir + `flock(LOCK_EX|LOCK_NB)` | named mutex | macOS has no abstract namespace. Must unlink stale sockets on start — the abstract version got that free |
|
||||
| `$XDG_RUNTIME_DIR` | `$TMPDIR` (per-user, already private) | `%LOCALAPPDATA%` | macOS has no XDG runtime dir |
|
||||
| `$XDG_DATA_HOME` | `~/Library/Application Support/Velox` | `%APPDATA%` | |
|
||||
| `libsecret` / Secret Service | Keychain (`Security.framework`) | DPAPI / Credential Manager | Behind the credential-store seam. CLAUDE.md §4 still applies: never SQLite, never logs |
|
||||
| `systemd` user units | `launchd` plist (`~/Library/LaunchAgents`) | Service/Task Scheduler | Phase 3 |
|
||||
|
||||
## Build dependencies
|
||||
|
||||
| Ubuntu | macOS (Homebrew) |
|
||||
|---|---|
|
||||
| `qt6-base-dev`, `qt6-svg-dev`, `qt6-tools-dev` | `qt@6` |
|
||||
| `libcurl4-openssl-dev` | system libcurl, or `curl` |
|
||||
| `libsqlite3-dev` | system sqlite, or `sqlite` |
|
||||
| `libssl-dev` | `openssl@3` (set `OPENSSL_ROOT_DIR`) |
|
||||
| `libsecret-1-dev` | **none** — Keychain is in the SDK |
|
||||
| `nlohmann-json3-dev` | `nlohmann-json` |
|
||||
| `nodejs`, `npm` | `node` |
|
||||
|
||||
The root `CMakeLists.txt` currently does `pkg_check_modules(LIBSECRET REQUIRED ...)`
|
||||
unconditionally. That must become Linux-only, or macOS cannot configure at all. It is the
|
||||
single hard blocker for a first macOS build.
|
||||
|
||||
## What does not change
|
||||
|
||||
`contracts/` and both generated clients, `tools/mockd`, `tests/conformance`,
|
||||
`tools/testserver`, the whole extension, and every hostile-mode expectation. If a port
|
||||
tempts you to change a fixture or a schema, stop — that is a contract change and it goes
|
||||
through PROTO.
|
||||
|
||||
## Verification gates
|
||||
|
||||
- **Phase 0 done:** Ubuntu suite still 57/57, and `git diff` shows only moves behind seams.
|
||||
- **Phase 1 done:** `veloxd`, `velox`, `libveloxcore` build on macOS; core unit tests pass.
|
||||
- **Phase 2 done:** conformance (mockd **and** live veloxd) and the engine hostile-mode
|
||||
matrix pass on macOS; one real download completes with a matching SHA-256.
|
||||
- **Phase 3 done:** `.dmg` or Homebrew formula installs and runs on a clean machine.
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR 0020 — Porting to macOS (and later Windows) without forking the tree
|
||||
|
||||
Status: **accepted** · Supersedes nothing · Applies to every lane
|
||||
|
||||
## Context
|
||||
|
||||
The project was built Ubuntu-first and says so in its own project description. A survey of
|
||||
`main` (2026-09-15, 57/57 green) found the Linux-specific surface is far smaller than the
|
||||
Ubuntu-first framing suggests — roughly ten files, all of them already isolated at the
|
||||
bottom of the stack:
|
||||
|
||||
| Area | Linux-only thing | Files |
|
||||
|---|---|---|
|
||||
| `core/io` | `posix_fallocate`, `posix_fadvise`, `fdatasync` | `sparse_file.cpp` |
|
||||
| `core/meta` | `fdatasync` | `veloxpart.cpp` |
|
||||
| `daemon/rpc` | `eventfd` | `event_loop.{hpp,cpp}`, `main.cpp` |
|
||||
| `daemon/rpc` | `timerfd` | `main.cpp`, `sched/scheduler.hpp` |
|
||||
| `daemon/rpc` | `SO_PEERCRED` / `struct ucred` | `uds_server.{hpp,cpp}` |
|
||||
| `daemon/rpc` | abstract-namespace socket lock | `single_instance.{hpp,cpp}` |
|
||||
| `daemon/rpc` | `$XDG_RUNTIME_DIR` layout | `runtime_dir.{hpp,cpp}` |
|
||||
| build | `libsecret-1` is `REQUIRED` at the root | `CMakeLists.txt` |
|
||||
|
||||
Everything else is already portable: the event loop is `poll(2)` (POSIX, not `epoll`), the
|
||||
transfer engine is libcurl, the GUI is Qt 6, `tools/mockd` and `tests/conformance` are
|
||||
Node, `tools/testserver` is stdlib Python, and the extension is a WebExtension.
|
||||
|
||||
## Decision
|
||||
|
||||
**One tree, one logic, per-OS backends behind seams.**
|
||||
|
||||
1. **No `#ifdef` in logic.** Platform differences live behind a narrow interface, with one
|
||||
implementation file per OS. A reader of `sparse_file.cpp` must not need to know which OS
|
||||
they are on. `#ifdef` is allowed only inside a `platform/<os>/` file.
|
||||
2. **Linux is the reference implementation.** A port may never change Linux behaviour. The
|
||||
gate is mechanical: the full suite stays green on Ubuntu, and the Linux backend keeps
|
||||
the same syscalls it uses today. If a port needs a semantic change, that is an ADR of
|
||||
its own, not a port commit.
|
||||
3. **Seams are introduced by the owning lane, on Linux, before any port work.** CORE
|
||||
introduces `core`'s seams; DAEMON introduces `daemon`'s. This is a pure refactor: move
|
||||
the existing Linux code behind the interface unchanged, prove the suite is still green.
|
||||
4. **A new lane, PORT, owns only the per-OS backends** — `**/platform/<os>/**` and
|
||||
`cmake/platform*.cmake`. It never writes logic. That keeps CLAUDE.md §1 intact: CORE
|
||||
still owns `core/`'s logic, DAEMON still owns `daemon/`'s, and macOS work can proceed in
|
||||
parallel without cross-lane writes.
|
||||
5. **`VELOX_OS_*` is set once, centrally**, in `cmake/platform.cmake`, and platform sources
|
||||
are selected there — not by globbing, and not per-target ad hoc.
|
||||
6. **Optional dependencies are gated, never removed.** `libsecret` stays `REQUIRED` on
|
||||
Linux and is replaced by Keychain on macOS behind the same credential-store seam.
|
||||
|
||||
## Phases
|
||||
|
||||
- **Phase 0 — on Ubuntu, by CORE + DAEMON.** Introduce the seams and move today's Linux
|
||||
code behind them. Zero behaviour change; 57/57 stays green. No macOS code exists yet.
|
||||
- **Phase 1 — on macOS, by PORT.** Implement the macOS backends until `veloxd`, `velox`
|
||||
and `libveloxcore` build and their unit tests pass.
|
||||
- **Phase 2 — parity.** `tools/testserver`, conformance and the engine's hostile-mode
|
||||
matrix pass on macOS. The GUI builds against Qt 6 for macOS.
|
||||
- **Phase 3 — packaging.** `.dmg`/Homebrew, `launchd` instead of `systemd`, and the macOS
|
||||
native-messaging manifest locations. Deliberately last.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Windows later is the same shape: a third backend directory, no new strategy. The seams
|
||||
chosen here are POSIX-flavoured but interface-level, so a Win32 backend is additive.
|
||||
- CI must build both, or macOS rots silently. Until a macOS runner exists, Phase 1 is
|
||||
verified by hand on the porting machine and the Linux gate stays authoritative.
|
||||
- The cost is one indirection at the bottom of the I/O and RPC stacks. Measured against
|
||||
the alternative — `#ifdef` drift, or a forked repo that diverges in a month — it is
|
||||
cheap.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent brief — PORT (per-OS backends)
|
||||
|
||||
**Starts after Phase 0: CORE and DAEMON have landed the seams on Ubuntu.**
|
||||
|
||||
## You own
|
||||
```
|
||||
core/src/**/platform/<os>/** daemon/src/**/platform/<os>/**
|
||||
cmake/platform*.cmake packaging/macos/** packaging/windows/**
|
||||
```
|
||||
You may read everything. **You never write logic** — not in `core/src/io/*.cpp`, not in
|
||||
`daemon/src/rpc/*.cpp`, not in `gui/`, never in `contracts/`.
|
||||
|
||||
## Read first
|
||||
`docs/adr/0020-cross-platform-strategy.md`, then `docs/08-porting.md` — it has the seam
|
||||
list, the API mapping and the dependency table. Then `CLAUDE.md`.
|
||||
|
||||
## The one rule that matters
|
||||
**Linux behaviour never changes.** If your port needs a seam that doesn't exist, or a seam
|
||||
whose shape is wrong, you do not widen it yourself — you file a request with the owning
|
||||
lane (CORE for `core/`, DAEMON for `daemon/`) exactly as every other lane does. A port that
|
||||
quietly edits shared logic is how one tree becomes two.
|
||||
|
||||
## Build order (macOS)
|
||||
1. **Configure at all.** The root `CMakeLists.txt` requires `libsecret-1` unconditionally;
|
||||
that must be Linux-gated before anything else compiles. File it with PKG/QA — root build
|
||||
files are theirs.
|
||||
2. `core/` — `preallocate`, `advise_dontneed`, `flush_durable`. Use `F_FULLFSYNC`, not
|
||||
`fsync`: `.veloxpart` resume correctness depends on a real flush.
|
||||
3. `daemon/` — `Wakeup` (self-pipe), `peer_of` (`getpeereid`), `instance_lock` (socket file
|
||||
+ `flock`, with stale-socket cleanup), `runtime_dir` (`$TMPDIR`, `~/Library/Application
|
||||
Support/Velox`).
|
||||
4. Unit tests green for both lanes.
|
||||
5. `tools/testserver` (stdlib Python) and conformance (Node) — expected to run unchanged.
|
||||
If they don't, that's a bug worth reporting, not patching around.
|
||||
6. GUI against Qt 6 for macOS.
|
||||
7. Packaging — `.dmg`/Homebrew, `launchd`, native-messaging manifest locations. Last.
|
||||
|
||||
## Definition of done (Phase 1–2)
|
||||
- macOS builds `libveloxcore`, `veloxd` and `velox` with `-Wall -Wextra -Werror`.
|
||||
- Core and daemon unit tests pass on macOS.
|
||||
- One real download completes on macOS against `tools/testserver` with a SHA-256 that
|
||||
matches the server's reference — the same gate the Linux vertical slice passed.
|
||||
- `kill -9` mid-download, restart, resume completes and the checksum still matches.
|
||||
- The Ubuntu suite is **still** 57/57 from the same commit.
|
||||
|
||||
## Do not
|
||||
- Do not add `#ifdef` outside a `platform/<os>/` file.
|
||||
- Do not change a fixture, a schema or a hostile-mode expectation to make macOS pass.
|
||||
- Do not substitute `F_NOCACHE` for `posix_fadvise(DONTNEED)` — see `docs/08` for why.
|
||||
- Do not start with packaging.
|
||||
Reference in New Issue
Block a user