PureYAML/docs/rules-swift/linux-server.md
Mihaela M 5f3892f2c1 chore(rules): vendor the rules-swift canon into docs/rules-swift
Mirror the shared Swift rules canon (rules-swift, commit 4ebb892) into
docs/rules-swift/ as a read-only reference alongside the repo's tailored
docs/rules/. PROVENANCE.md records the source, commit, and what was
included or excluded; the upstream repo's own VCS, CI, and lint tooling
is omitted as machinery rather than rule content.

Exclude the vendored tree from the style gate (scripts/check-style.sh)
and the pre-commit hook: the canon's enforcement docs quote the
forbidden attribution phrases to define them, the same reason the
repo's own enforcement files are already excluded.
2026-06-26 05:36:12 +02:00

393 lines
18 KiB
Markdown

# Linux-server Swift
Operational rules for Tiledown code that runs on Linux in production: HTTP client, logging, database, crypto, signals, resources, POSIX interop, file I/O, and Docker CI.
Load on demand. Triggers: Linux, server, Vapor, Hummingbird, AsyncHTTPClient, swift-log, swift-crypto, FluentSQLite, FluentPostgres, NIO, SIGTERM, graceful shutdown, Docker, `swift:6.0-jammy`, `swift:6.0-noble`, Bundle.module, FoundationNetworking on a server, isatty, BoringSSL.
If your target runs on Linux in production (server, CLI binary, container workload), this file is the operational rule. The higher-level topology rule and the `#if canImport(...)` patterns live in `docs/rules/cross-platform.md`; this file is the server-specific operational layer.
## When this rule applies
- Building a Vapor / Hummingbird / NIO server that ships to Linux (Docker, ECS, Kubernetes).
- Building a Swift CLI that supports Linux installation (Homebrew on macOS, package manager or `swift build` on Linux).
- Building a server that ships cross-platform.
- Any repo with a Linux-buildable product in its Package.swift (Topology 2 or 3 from `cross-platform.md`).
If the answer is "Apple-only iOS/macOS app, no Linux," skip this file; load `cross-platform.md` for the Apple-cross-platform patterns instead.
Note: the TileKit engine targets macOS and Linux. Both support subprocess and shell-out, so the engine may spawn subprocesses. As a design preference, keep server-only concerns (this file's HTTP/database/signal patterns) in dedicated CLI/server targets rather than the core, so the core stays focused; this is an organizational choice, not a platform limitation.
## 1. HTTP: AsyncHTTPClient, not URLSession
The canonical HTTP client on Linux server code is `AsyncHTTPClient` (swift-server). Use it on every platform for cross-platform parity, not just Linux.
```swift
import AsyncHTTPClient
import NIOHTTP1
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton)
let request = HTTPClientRequest(url: "https://example.com/api")
let response = try await httpClient.execute(request, timeout: .seconds(30))
let body = try await response.body.collect(upTo: 1024 * 1024)
```
**Why not URLSession on Linux:** `FoundationNetworking`'s URLSession on Linux lacks `URLSessionWebSocketTask`, `URLSessionStreamTask`, and PAC (proxy auto-configuration). For any feature beyond plain `data(from:)`, you will hit gaps.
**Why use AsyncHTTPClient on Apple too:** a single HTTP client across all platforms eliminates the entire `#if canImport(FoundationNetworking)` class of bug. The cost is one dependency. You can tune one `HTTPClient` config (HTTP/1.1 only, short idle timeout, POSIX sockets) and run the same code on macOS and Linux.
**TLS:** AsyncHTTPClient ships bundled BoringSSL on Linux; on Apple it uses Security.framework transparently. No source-level branching needed.
If you must use URLSession (existing library code, an Apple-specific feature), see `cross-platform.md` Pattern 10 for the `canImport(FoundationNetworking)` form.
**URLSession lifetime on Linux (FoundationNetworking):** `URLSession.deinit` asserts that the session was invalidated before release. On Apple platforms this is advisory; on Linux it crashes with "Object deallocated with non-zero retain count" at process exit. Always invalidate before the session goes out of scope:
```swift
// Wrong: session deallocated without invalidation, Linux crash
let session = URLSession(configuration: .ephemeral)
// ... use session ...
// session goes out of scope here → crash
// Correct option 1: final class with deinit (preferred, automatic for all callers)
public final class TileDownloader: @unchecked Sendable {
private let session: URLSession
init() { session = URLSession(configuration: .ephemeral) }
deinit { session.invalidateAndCancel() }
}
// Correct option 2: explicit invalidation at call site
defer { session.invalidateAndCancel() }
```
Use `invalidateAndCancel()` rather than `finishTasksAndInvalidate()` when all tasks are already done. `invalidateAndCancel` sets `invalidated = true` synchronously, whereas `finishTasksAndInvalidate` does so async on the work queue and may not run before process exit. Sessions created with a delegate (e.g., for TLS handling) are especially prone to this: the session retains the delegate, and the retain cycle is only broken on invalidation.
## 2. Logging: swift-log on the server, `canImport(os)` only for local dev
Two-tier strategy:
**Server tier (Vapor / Hummingbird / NIO target):** use `swift-log` (the `Logging` package) exclusively. Bootstrap once at startup:
```swift
import Logging
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardError(label: label)
handler.logLevel = .info
return handler
}
let logger = Logger(label: "com.example.tiledown")
logger.info("server starting")
```
In production, redirect stderr to journald / Docker logs / your hosting platform's log sink.
**App tier (iOS / macOS UI):** wrap `os.log` with a `canImport(os)` conditional and a print fallback:
```swift
#if canImport(os)
import os.log
public struct TileLogger {
private let logger: Logger
public init(subsystem: String, category: String) {
self.logger = Logger(subsystem: subsystem, category: category)
}
public func info(_ message: String) { logger.info("\(message, privacy: .public)") }
public func error(_ message: String) { logger.error("\(message, privacy: .public)") }
}
#else
public struct TileLogger {
public init(subsystem: String, category: String) {}
public func info(_ message: String) { print("[INFO] \(message)") }
public func error(_ message: String) { print("[ERROR] \(message)") }
}
#endif
```
The Apple side uses unified logging (Console, Instruments). The Linux side uses print. Same call site, different sink.
**Do not mix the two in the same file.** `Logger` (swift-log) and `Logger` (os.log) are different types with the same name. Trying to use both in one source file requires fully qualified names (`Logging.Logger` vs `os.Logger`) and is brittle. Pick one per target.
Reference: SSWG `swift-log` is the standard for the server tier.
## 3. Database: Fluent on Linux, avoid CoreData
CoreData is Apple-only; do not reach for it on the server. Options on Linux:
- **Vapor + Fluent + FluentSQLiteDriver** (local development) / **FluentPostgresDriver** (production), the dominant SSWG stack.
- **GRDB**, a cross-platform SQLite wrapper, single binary, no actor abstractions.
- **swift-sqlite** (the raw `sqlite3` C module imported via Swift), the lowest level, lowest overhead. A good fit for FTS5 search indexes.
**CloudKit, iCloud Drive, NSPersistentCloudKitContainer:** Apple-only. For Linux backends that need sync, build it explicitly over your chosen database driver.
## 4. Cryptography: swift-crypto cross-platform, CryptoKit Apple-only
The standard conditional:
```swift
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
```
This imports correctly on both platforms. But the APIs are NOT fully source-compatible. Real gotchas:
- **`SHA256.hash` vs `SHA256.digest`**: CryptoKit exposes `Digest` types differently. The hash methods return `Digest` on CryptoKit and `Digest` on Crypto, but their `description`, `Sequence` conformance, and bytewise iteration can differ at the protocol level. Test both sides.
- **`SecureEnclave`** does not exist in swift-crypto. Hardware-backed keys are Apple-only.
- **`SymmetricKey(data:)`** is identical on both. `ChaChaPoly`, `AES.GCM`, `HKDF`, `Curve25519` are all source-compatible.
- **`P256.Signing.PrivateKey.publicKey.derRepresentation`** is identical.
When in doubt: write the code against `Crypto` first (smaller surface, cross-platform), only reach for `CryptoKit`-specific APIs (SecureEnclave, biometric-backed keys) at Apple-only call sites with explicit `#if canImport(CryptoKit)` guards.
## 5. Graceful shutdown: SIGTERM grace then SIGKILL
Server containers receive SIGTERM on shutdown (Docker stop, Kubernetes pod termination, systemd stop). The correct response: stop accepting new work, drain in-flight requests, exit cleanly. Vapor and Hummingbird handle the basic SIGTERM hook automatically; your server's `application.run()` returns when SIGTERM arrives.
For multi-process supervisors (a parent that forks server children), the reaper pattern:
```swift
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
func reapChild(pid: pid_t, graceSeconds: Double = 2.0) {
_ = kill(pid, SIGTERM)
let deadline = Date().addingTimeInterval(graceSeconds)
while Date() < deadline {
var status: Int32 = 0
if waitpid(pid, &status, WNOHANG) == pid { return } // exited cleanly
Thread.sleep(forTimeInterval: 0.1)
}
_ = kill(pid, SIGKILL)
_ = waitpid(pid, nil, 0)
}
```
POSIX signal semantics are identical on Linux and Darwin (sigaction, signal handlers, realtime signals). The wrapper is for the `Darwin` vs `Glibc` import only.
**Gotcha:** `proc_pidpath()` is Darwin-only. To resolve a Linux child's binary path, read `/proc/$pid/exe` as a symlink. Do not assume `proc_pidpath` works cross-platform.
**For Vapor servers specifically:** Vapor calls `application.shutdown()` on SIGTERM, which drains the event loop group. Do not reinvent. For Hummingbird, the equivalent is `application.shutdownGracefully()`.
## 6. Resources: compile to Swift, do not rely on `Bundle.module`
`Bundle.module` is Apple-only as a fully reliable API. On Linux SPM, `Bundle.module` either does not exist or returns paths that do not match expectations. On macOS with Homebrew installs, the binary symlink trap breaks `Bundle.module` lookups even on Apple. Both failure modes have the same fix: compile resources as Swift code.
The pattern (in a `Resources` target):
```swift
// Embedded/Static.swift (generated)
extension Embedded {
static let initialSeedJSON: String = """
{
"key": "value",
...
}
"""
}
```
A build-time generator script reads the resource file, escapes it as a Swift string literal, and writes the `.swift` file. At runtime, no bundle lookup, no FS access, no symlink resolution. Works identically on every platform.
When this pays off:
- Resource is small (< 1 MB total per file works fine in a string literal).
- Resource is read-only at runtime.
- Resource is needed at startup (no lazy disk access).
- You are shipping a binary to multiple package managers (Homebrew, apt, MSI) and do not want to debug each one's resource-path semantics.
When to NOT use:
- Large binary assets (images, sound, large CSV). Use SPM's `resources: [.process("...")]` and read at runtime; accept the platform-specific resource lookup.
- Resources that change per-installation (user data, downloaded content).
## 7. POSIX C interop: Darwin / Glibc / Musl
For raw POSIX calls (read, write, ioctl, signal, kill, waitpid, dup2, pipe), the canonical conditional:
```swift
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif
```
Musl is reserved for Alpine Linux containers; most production deployments use glibc (Ubuntu, Debian, Amazon Linux 2, RHEL). Test on Musl only if your container base is Alpine.
**Modern alternative, `apple/swift-system`:**
```swift
import SystemPackage
let fd = try FileDescriptor.open("/path", .readOnly)
defer { try? fd.close() }
let bytesRead = try fd.read(into: buffer)
```
`swift-system` provides a unified Swift surface for POSIX file I/O, file paths, errno handling. Use it for new code instead of raw Darwin/Glibc imports. Reference: SYS-0008 (`CInterop.stat` back-deploy), SE-0063, SE-0208.
**When to reach for raw imports:** legacy code, operations not covered by `swift-system` (signal handlers, exec / posix_spawn, terminal ioctls).
## 8. FileHandle write API diverges
`FileHandle.write(_:)` has different signatures across platforms:
- macOS / iOS: `func write(_ data: Data)`, non-throwing, never actually fails
- Linux (swift-corelibs-foundation): `func write(contentsOf: Data) throws`, throws on error
Code that compiles on one platform may not compile on the other. The wrapper:
```swift
extension FileHandle {
func write(contentsOf data: Data) throws {
#if canImport(Darwin)
write(data)
#else
try write(contentsOf: data)
#endif
}
}
```
Use this wrapper anywhere FileHandle writes are needed in cross-platform code. Apple platforms swallow errors silently (consistent with their non-throwing API); Linux propagates.
**Alternative:** switch to `swift-system`'s `FileDescriptor.write(_:)`, which has the same throwing signature on every platform.
## 9. Piped stdio detection: `isatty()`
When a CLI binary is piped (shell pipeline, log collector, host process), interactive prompts and TUI escape sequences must be suppressed. Standard POSIX check, works identically on Linux and Darwin:
```swift
import Foundation
let isPiped = isatty(STDOUT_FILENO) == 0
if isPiped {
// protocol mode: structured output only
} else {
// interactive mode: colours, prompts, redraws
}
```
Use this to disable terminal logging when the binary is invoked by another process rather than an interactive shell.
## 10. Test framework on cross-platform server code
XCTest works on Linux (auto-discovery since Swift 5.4; no `LinuxMain.swift` required). Swift Testing is platform-agnostic by design. For new tests in any Linux-buildable target, default to Swift Testing.
```swift
import Testing
@Test func serverHealthEndpointReturns200() async throws {
let response = try await client.execute(.health)
#expect(response.status == .ok)
}
```
Mixed XCTest / Swift Testing in the same target is fine; both runners coexist. See `concurrency.md` for the broader rule.
References: ST-0009, ST-0016, ST-0017, ST-0022 (Swift Testing parity across Apple, Linux, Windows).
## 11. CI for Linux-buildable server products
Every Linux-buildable product runs a Linux build on every PR. Minimum GitHub Actions job:
```yaml
linux-build:
runs-on: ubuntu-latest
container: swift:6.0-jammy
steps:
- uses: actions/checkout@v4
- run: swift build -c release --product <your-linux-product>
```
For testing, add a parallel job that runs `swift test --filter <LinuxBuildableSuite>` against the Linux-buildable test suites.
**Docker image choice:**
- `swift:6.0-jammy`, Ubuntu 22.04 LTS, the default for most production deploys.
- `swift:6.0-noble`, Ubuntu 24.04 LTS, newer.
- `swift:6.0-jammy-slim`, runtime-only base for the production stage.
- `swift:6.0-bookworm`, Debian 12, for some legacy deploys.
- `swift:6.0-alpine`, Alpine + Musl, smallest image. Test Musl conditionals if you use this.
**Production deployment via Docker:**
```dockerfile
FROM swift:6.0-jammy as build
WORKDIR /src
COPY . .
RUN swift build -c release --product tiledownserver
FROM swift:6.0-jammy-slim
COPY --from=build /src/.build/release/tiledownserver /usr/local/bin/
EXPOSE 8080
CMD ["tiledownserver"]
```
Multi-stage builds keep the runtime image small. The Docker production path can catch catastrophic failures, but it is not a substitute for PR-time Linux CI; deploy-time discovery means broken builds reach main.
## 12. Manifest discipline (Topology 2: Apple clients + Linux server)
If your repo is the Apple-clients-plus-Linux-server split (`cross-platform.md` Topology 2), the manifest pattern from `cross-platform.md` Layer A applies:
```swift
let baseProducts: [Product] = [
.singleTargetLibrary("TileKit"),
.singleTargetLibrary("TileServer"),
.executable(name: "tiledownserver", targets: ["TileServerApp"]),
// ... unconditional, Linux-buildable
]
#if os(iOS) || os(macOS)
let appleOnlyProducts: [Product] = [
.singleTargetLibrary("TileDownUI"),
.singleTargetLibrary("TileDownComponents"),
// ... Apple-only UI
]
#else
let appleOnlyProducts: [Product] = []
#endif
let products = baseProducts + appleOnlyProducts
```
Same shape for targets. Server-side products and targets stay unconditional. UI-side wraps in the conditional array.
Document the Linux-buildable surface in the repo's `README.md`: name the product (`tiledownserver`), name the build command (`swift build -c release --product tiledownserver`), name the supported Docker image. Otherwise new developers running plain `swift build` on Linux hit Apple-target compile errors.
## 13. Cross-references
- **`docs/rules/cross-platform.md`** is the higher-level topology rule and covers UI patterns + library patterns. This file is the server-specific operational layer; load both together for a server repo.
- **`concurrency.md`** is platform-agnostic; Sendable + async/await behave identically on Apple and Linux.
## 14. Authoritative references
- **SSWG packages:** `swift-server/async-http-client`, `apple/swift-log`, `apple/swift-crypto`, `apple/swift-system`, `apple/swift-nio`.
- **Vapor / Hummingbird:** their respective package docs.
- **System interop:** SE-0063, SE-0208 (System packages). SYS-0008 (`CInterop.stat` back-deploy).
- **Swift Testing platforms:** ST-0009, ST-0016, ST-0017, ST-0022.
- **swift-foundation architecture:** `swiftlang/swift-foundation` README for the three-tier model (Darwin / swift-foundation / swift-corelibs-foundation).
## Quick reference table
| Concern | Apple-only choice | Cross-platform / Linux server choice |
|---|---|---|
| HTTP client | URLSession | AsyncHTTPClient |
| Logging | `os.Logger` | swift-log (`Logging`) |
| Crypto | CryptoKit | swift-crypto (`Crypto`) |
| Database | CoreData | Fluent + SQLite/Postgres driver, GRDB, or raw `sqlite3` |
| C interop | `import Darwin` | `swift-system` preferred; `canImport(Darwin/Glibc/Musl)` for legacy |
| FileHandle write | `write(_:)` non-throwing | wrap with `canImport(Darwin)` (see § 8) |
| Resources | `Bundle.module` | compile to Swift code |
| Tests | XCTest or Swift Testing | Swift Testing (platform-agnostic) |
| Shutdown | OS handles UI lifecycle | SIGTERM grace + SIGKILL |
| CI | macOS only | macOS + Linux Docker on every PR |
| Production runtime | App Store / Mac App | Docker container (jammy-slim base) |