diff --git a/Sources/SakeKit/Bottle.swift b/Sources/SakeKit/Bottle.swift index 5ef45c6..2d3b0b5 100644 --- a/Sources/SakeKit/Bottle.swift +++ b/Sources/SakeKit/Bottle.swift @@ -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 } diff --git a/Tests/SakeKitTests/BottleTests.swift b/Tests/SakeKitTests/BottleTests.swift index 1115f47..06a1102 100644 --- a/Tests/SakeKitTests/BottleTests.swift +++ b/Tests/SakeKitTests/BottleTests.swift @@ -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) } diff --git a/docs/gdk.md b/docs/gdk.md index 8faf5fe..c24e926 100644 --- a/docs/gdk.md +++ b/docs/gdk.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index a28640c..bd463c2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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, diff --git a/docs/runtime.md b/docs/runtime.md index 35766da..33610d5 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 @@ -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). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 82e2e4c..549467d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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