Skip to content

Commit 1ab8b89

Browse files
Lock model switcher while running, rework Settings Server section (#29)
* feat: lock model switching while running and rework Settings' Server group - The popup variant picker locks while ds4-server is starting/ready; switching models takes Stop first. - Settings: the V4 Flash/V4.1 variant pickers move into the Server group just above Apply & Restart, cleanup folds into Downloads, and the App/Server/picker prose is gone. - Apply & Restart enables only while the live Server-group controls differ from the running server's published launch config (LaunchConfig), and Start clears the dirty state too. - "Concurrent sessions" becomes a fits-bounded "Parallel chats & agents" stepper with a live per-slot memory/fit caption. - V4.1 Flash: shorter 41-q2/41-q4 labels (prefixes kept) and a 1,048,576 default when the 1M window fits fully resident, else upstream's 32,768. - The Settings window matches the wired-limit help pane's 600-wide frame, with content height capped at 820. * fix: report zero fitting sessions when none pass the wired-limit gate The sessions caption claimed one slot fit whenever even a single session needed the Start-anyway override. maxFittingSessions now returns 0 when nothing passes, Settings renders a distinct no-slot-fits caption, and the stepper range keeps its one-slot minimum. * fix: clear active config when server launch fails * fix: terminate the timed-out server before publishing the startup failure * fix: clear active config on every runtime error transition * fix: stop failed server before allowing retry * fix: stop the stderr reader when a server launch fails * test: pin the refused-launch path in the stderr reader source guard --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 80edfb8 commit 1ab8b89

15 files changed

Lines changed: 672 additions & 157 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ make -C external/ds4 -j ds4-server # build the ds4-server binary
6161
DS4_DIR="$PWD/external/ds4" swift run # build + run the dev app against the submodule
6262
```
6363

64-
`DS4_DIR` points the app at that ds4 checkout so **Start** can spawn `ds4-server` — without it the dev build looks for a bundled `ds4/` next to the binary and won't find the server. It's a menu-bar app (no dock icon): after launch, click the bolt icon in the macOS menu bar, pick **Pro**, **V4.1 Flash**, or **Flash (0731)** (preselected by your RAM), then **Download** (if the model isn't present) and **Start**.
64+
`DS4_DIR` points the app at that ds4 checkout so **Start** can spawn `ds4-server` — without it the dev build looks for a bundled `ds4/` next to the binary and won't find the server. It's a menu-bar app (no dock icon): after launch, click the bolt icon in the macOS menu bar, pick **Pro**, **V4.1 Flash**, or **Flash (0731)** (preselected by your RAM), then **Download** (if the model isn't present) and **Start**. The popup's picker locks while the server is loading or running, so switching models later takes **Stop** first; the per-generation weight choice lives in Settings next to **Apply & Restart Server**.
6565

6666
## Requirements
6767

@@ -90,7 +90,7 @@ sudo sysctl iogpu.wired_limit_mb=<value shown in the popup>
9090

9191
and a "Metal wired limit help…" window walks through it step by step — including making it survive reboots (the sysctl resets on every restart, which is why a setup that worked before can hang after one). A confirmed "Start anyway" override remains if you know your setup works. This applies most often to 96–127 GiB machines running Flash q2, but also to V4 Pro on 512 GiB and Flash q2-q4 at 1M context on 128 GiB when their default cap is below the working set.
9292

93-
**Default context** is `1,000,000` for Pro and for Flash on ≥ 128 GiB; Flash on 96–127 GiB defaults to `256,000`; **V4.1 Flash defaults to `32,768`** (upstream's documented SSD-streaming configuration — raise it in Settings when memory allows, up to its 1,048,576 ceiling). Max Think is unavailable below 128 GiB, including in the built-in chat and coding-agent launcher. For V4.1 Flash, `--ssd-streaming` is passed automatically when full residency doesn't fit and full GPU power is always used; Engram rows stream from the SSD in every mode, so keep the GGUF on a fast local disk. You can otherwise override the context in Settings, subject to the physical-memory and wired-limit checks above.
93+
**Default context** is `1,000,000` for Pro and for Flash on ≥ 128 GiB; Flash on 96–127 GiB defaults to `256,000`; **V4.1 Flash defaults to `1,048,576` when the 1M window fits fully resident at your RAM tier (41-q2 from 256 GiB, 41-q4 from 384 GiB), else to `32,768`** (upstream's documented SSD-streaming configuration — raise it in Settings when memory allows, up to its 1,048,576 ceiling). Max Think is unavailable below 128 GiB, including in the built-in chat and coding-agent launcher. For V4.1 Flash, `--ssd-streaming` is passed automatically when full residency doesn't fit and full GPU power is always used; Engram rows stream from the SSD in every mode, so keep the GGUF on a fast local disk. You can otherwise override the context in Settings, subject to the physical-memory and wired-limit checks above.
9494

9595
## Thinking
9696

‎Sources/DS4Control/Model/Feasibility.swift‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -671,13 +671,22 @@ func defaultFlash41Quant(ramGiB: Double) -> Flash41Quant {
671671

672672
/// Default context, tiered by model generation and machine memory. V4 Pro and ≥128 GiB
673673
/// Flash (q2-q4 quant) run the full 1M window all-resident; 96–127 GiB Flash (q2) is
674-
/// capped at 256K. V4.1 Flash defaults to 32,768 — upstream's documented SSD-streaming
675-
/// configuration (docs/MODELS.md@bd66c40); raise it from Settings when memory allows.
674+
/// capped at 256K. V4.1 Flash defaults to the 1,048,576 ceiling when the machine holds that
675+
/// window fully resident (the auto `--ssd-streaming` heuristic says no streaming is needed
676+
/// at this RAM tier — 41-q2 from 256 GiB, 41-q4 from 384 GiB), else to upstream's documented
677+
/// 32,768 SSD-streaming configuration (docs/MODELS.md@bd66c40); both are adjustable from
678+
/// Settings.
676679
func defaultCtx(ramGiB: Double, selection: QuantSelection) -> Int {
677-
switch selection.variant {
680+
switch selection {
678681
case .pro: return Variant.pro.ctxCeiling
679682
case .flash: return ramGiB >= 128 ? Variant.flash.ctxCeiling : 256_000
680-
case .flash41: return 32_768
683+
case .flash41(let q):
684+
guard ramGiB >= flash41MinRamGiB(q) else { return 32_768 }
685+
let ceiling = Variant.flash41.ctxCeiling
686+
let streaming = flash41UsesSSDStreaming(
687+
ramGiB: ramGiB, wiredLimitMB: wiredLimitAdvisoryMB(ramGiB: ramGiB),
688+
quant: q.quant, ctx: ceiling, sessions: 1)
689+
return streaming ? 32_768 : ceiling
681690
}
682691
}
683692

@@ -743,3 +752,22 @@ func feasibility(
743752
ramGiB: ramGiB, selection: variant == .pro ? .pro : .flash(flashQuant),
744753
ctx: ctx, wiredLimitMB: wiredLimitMB, sessions: sessions)
745754
}
755+
756+
/// Largest resident-session count whose launch config passes the wired-limit gate at the
757+
/// live limit without the "Start anyway" override, capped at `maxConcurrentSessions`.
758+
/// Returns 0 when even one session fails the gate — the config needs the override (or is
759+
/// blocked outright), which the Start/Restart gate reports; callers keep the stepper's
760+
/// minimum at one.
761+
func maxFittingSessions(
762+
ramGiB: Double, selection: QuantSelection, ctx: Int, wiredLimitMB: Int
763+
) -> Int {
764+
for sessions in stride(from: maxConcurrentSessions, through: 1, by: -1) {
765+
if case .standard = feasibility(
766+
ramGiB: ramGiB, selection: selection, ctx: ctx, wiredLimitMB: wiredLimitMB,
767+
sessions: sessions)
768+
{
769+
return sessions
770+
}
771+
}
772+
return 0
773+
}

‎Sources/DS4Control/Model/ServerState.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ enum ServerState: Equatable {
2222
case ready
2323
case stopping
2424
case error(ServerError)
25+
26+
/// A ds4-server process is loading or serving. The popup locks its model picker then:
27+
/// it has no Apply & Restart affordance, so switching models means Stop first.
28+
var isServerActive: Bool { self == .starting || self == .ready }
2529
}
2630

2731
struct DownloadProgress: Equatable {

‎Sources/DS4Control/Model/Variant.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,12 @@ enum Flash41Quant: String, CaseIterable, Identifiable, Codable {
212212
case .q4: return .q41Q4
213213
}
214214
}
215-
/// Picker label: generation + internal key + resident size, e.g.
216-
/// "41-q2 · ~152 GiB resident (341 GiB on disk)".
215+
/// Picker label: weights key + resident main weights + file size on disk (the file also
216+
/// carries ~189 GiB of disk-only Engram tables), e.g. "41-q2 · ~152 GiB (341 GiB on disk)".
217217
var label: String {
218218
switch self {
219-
case .q2: return "41-q2 · ~152 GiB resident (341 GiB on disk)"
220-
case .q4: return "41-q4 · ~294 GiB resident (483 GiB on disk)"
219+
case .q2: return "41-q2 · ~152 GiB (341 GiB on disk)"
220+
case .q4: return "41-q4 · ~294 GiB (483 GiB on disk)"
221221
}
222222
}
223223
}

‎Sources/DS4Control/Services/ProcessRunner.swift‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ protocol ProcessRunner: AnyObject {
6161
var isRunning: Bool { get }
6262
}
6363

64+
enum ProcessRunnerError: Error, Equatable {
65+
case alreadyRunning
66+
}
67+
6468
/// Buffers a process's stderr into newline-delimited lines. `FileHandle` invokes the
6569
/// readability handler serially on its own private queue, so the mutable buffer is never
6670
/// accessed concurrently — hence the `@unchecked Sendable` conformance (Swift 6 mode).
@@ -90,9 +94,10 @@ private final class StderrLineReader: @unchecked Sendable {
9094

9195
final class RealProcessRunner: ProcessRunner {
9296
private var process: Process?
97+
private let processLock = NSLock()
9398
private let queue = DispatchQueue(label: "ds4.process")
9499

95-
var isRunning: Bool { process?.isRunning ?? false }
100+
var isRunning: Bool { processLock.withLock { process?.isRunning ?? false } }
96101

97102
func launch(
98103
executable: URL, args: [String], cwd: URL, env: [String: String],
@@ -118,8 +123,20 @@ final class RealProcessRunner: ProcessRunner {
118123
reader.stop()
119124
onExit(proc.terminationStatus)
120125
}
121-
try p.run()
122-
self.process = p
126+
do {
127+
try processLock.withLock {
128+
guard process?.isRunning != true else { throw ProcessRunnerError.alreadyRunning }
129+
try p.run()
130+
process = p
131+
}
132+
} catch {
133+
// Neither a refused launch nor a failed `Process.run()` reaches
134+
// `terminationHandler` — the only other reader stop — so clear the handler or it
135+
// leaks with the pipe's descriptors (reader → FileHandle → readabilityHandler
136+
// keeps the cycle alive).
137+
reader.stop()
138+
throw error
139+
}
123140
}
124141

125142
static func childEnvironment(
@@ -132,7 +149,7 @@ final class RealProcessRunner: ProcessRunner {
132149
}
133150

134151
func terminate(graceSeconds: Double) {
135-
guard let p = process else { return }
152+
guard let p = processLock.withLock({ process }) else { return }
136153
// download_model.sh spawns `hf` as a child; SIGTERM to the shell alone orphans
137154
// hf, which keeps holding the hf download lock and blocks the next attempt.
138155
// Capture descendants *before* killing (they reparent to launchd once the shell

0 commit comments

Comments
 (0)