Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Sources/SakeKit/Bottle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ public struct Bottle: Sendable, Equatable {
/// and because it is an inode it survives the bottle being renamed. Measured against a
/// live prefix on 2026-09-20; see docs/runtime.md.
public var serverDirectory: URL? {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
// Resolved first: Wine `chdir`s into the prefix and stats `.`, so a bottle that is a
// symlink is named by its target, and `attributesOfItem` describes the link itself.
guard let attributes = try? FileManager.default.attributesOfItem(
atPath: url.resolvingSymlinksInPath().path
),
let device = attributes[.systemNumber] as? Int,
let inode = attributes[.systemFileNumber] as? Int
else { return nil }
Expand Down
21 changes: 21 additions & 0 deletions Tests/SakeKitTests/BottleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,27 @@ private func trash(in paths: Paths) -> (can: URL, trash: Trash) {
#expect(renamed.serverDirectory?.path == expected)
}

@Test func aBottleThatIsASymlinkIsNamedByThePrefixItPointsAt() throws {
let paths = temporaryRoot()
defer { remove(paths) }

// The `ex` bottle on 2026-09-29: a link in bottles/ to a prefix on another disk. Wine
// names the socket directory after the prefix, and the link's own inode names one that
// never exists, so Stop found nothing to stop.
let prefix = paths.root.appending(path: "elsewhere/ex")
try FileManager.default.createDirectory(at: prefix, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: paths.bottles, withIntermediateDirectories: true)
let bottle = Bottle(paths: paths, name: "ex")
try FileManager.default.createSymbolicLink(at: bottle.url, withDestinationURL: prefix)

let attributes = try FileManager.default.attributesOfItem(atPath: prefix.path)
let device = attributes[.systemNumber] as! Int
let inode = attributes[.systemFileNumber] as! Int
let expected = "/tmp/.wine-\(getuid())/server-"
+ String(device, radix: 16) + "-" + String(inode, radix: 16)
#expect(bottle.serverDirectory?.path == expected)
}

@Test func aPrefixThatIsNotThereHasNoProcessesAndNothingToKill() async throws {
let paths = temporaryRoot()
defer { remove(paths) }
Expand Down
9 changes: 4 additions & 5 deletions docs/gdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,10 @@ An installer is given it too, because Steam's can start Steam as it finishes, an
installed in that Steam never passes through sake's Play; that has not been tried. A copy is
written beside the one it replaces and renamed over it, so nothing ever reads half a DLL. On
APFS a game that has the old one loaded keeps it; on exFAT, where the `ex` bottle is, what that
rename does under a running game has not been measured, and sake cannot yet see what runs in a
symlinked bottle ([#15](https://github.com/typester/sake/issues/15)) to wait for it. The engine
is one per Mac, so two copies of sake that carry different runtime source each take the other's
build for stale and build their own again; only a development build run beside a release does
that.
rename does under a running game has not been measured, and sake does not check what is running
in the bottle before it replaces the copy. The engine is one per Mac, so two copies of sake that
carry different runtime source each take the other's build for stale and build their own again;
only a development build run beside a release does that.

**libHttpClient is pinned by what it unpacks to.** The step fetches it itself rather than with
the other sources, so that nothing else in setup waits on it. GitHub's page on downloading
Expand Down
4 changes: 1 addition & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,7 @@ easier to read than it was interleaved with `configure` flags.
through its socket directory ([runtime.md](runtime.md#taking-a-bottle-down)), but the guard
that refuses to rename a bottle with a game in it still asks what sake started, so it is
narrower than it needs to be. A wineserver one of Wine's tools started is not something it
knows about, so running winecfg does not refuse a rename the way a running game does. In a
bottle that is a symlink the socket directory is not found at all
([#15](https://github.com/typester/sake/issues/15)).
knows about, so running winecfg does not refuse a rename the way a running game does.
- **`AppModel` has quietly become where decisions live, and the tests cannot reach it.** Whether
a bottle may be renamed, which run a title's status belongs to, which variable names a title
may not set (`typedEnvironment()`), and what to forget after an uninstall are all judgements,
Expand Down
14 changes: 11 additions & 3 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,9 +521,7 @@ rather than an assumption of success. Stopping the `wine` sake started is not en
`Agent.exe` runs with ppid 1, wineserver is its own daemon, and Battle.net keeps a fistful of
CEF helpers (prototype). `wineserver -k` alone leaves the prefix's own services running, `ps`
cannot say which prefix a process belongs to, and the directory wineserver keeps its socket in
can. It finds nothing in a bottle that is a symlink: `Bottle.serverDirectory`
reads the link's own inode rather than the prefix's
([#15](https://github.com/typester/sake/issues/15)).
can.

**`wineserver -k` leaves the prefix's own services behind.** A title was started from the
library and stopped again: `wineserver -k` took down the game and the server, and then these
Expand Down Expand Up @@ -565,3 +563,13 @@ else: the clients hold the server's `tmpmap-*` shared memory open, so they are s
**after the server itself is gone** (sake, 2026-09-20). An inode does not change when a
directory is renamed, so this identifies a bottle's processes across a rename; and it is per
prefix, so nothing here can reach a CrossOver bottle or another of sake's.

**A bottle that is a symlink is named by what the link points at.** Wine `chdir`s into
`WINEPREFIX` and stats `.`, in ntdll and wineserver alike (read in CrossOver 26.3.0's
sources, 2026-10-01), while `FileManager.attributesOfItem` describes a link itself, so
reading the bottle's own path with it names a directory that never exists. In the `ex`
bottle, a link to a directory on an exFAT disk, the link is `1000012-8fa3a84` and the
server's directory `server-1000016-116a1`. Through the link's own inode, Stop killed
wineserver and left nineteen processes running with ppid 1 (sake, 2026-09-29); through its
target, the sequence Stop runs found eighteen there with Steam up, wineserver and seven
`steamwebhelper.exe` among them, and left none (sake, 2026-10-01).
4 changes: 1 addition & 3 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ the Switch Pro Controller's, so a controller should work. If yours is not seen,
## After you quit

- **The Dock still shows sake after it has quit.** Something sake started is still running.
**Stop** on a title takes down everything in its bottle, not only the game. In a bottle that
is a symlink to another disk, Stop does not find what runs there
([#15](https://github.com/typester/sake/issues/15)).
**Stop** on a title takes down everything in its bottle, not only the game.

## Removing sake

Expand Down
Loading