Skip to content

Commit 3cb4003

Browse files
samartomarclaude
andcommitted
Let auto use what is ready, and make naming the engine the thing that builds it
CI found a real one, and it was not about CI: the macOS leg went from 35 seconds on main to 643 on this branch. Every launch on a Mac without Whisper models was compiling Swift and then sitting on `--probe` until it timed out, because the probe waits for an authorization dialog that a headless machine never answers. A user's first launch would have done exactly the same thing. A full minute of nothing before a pill appears, on a machine that asked for none of it, to decide whether to offer an engine it might not use. So `--engine auto` passes compile_if_missing=False and a ten second probe: it uses the helper if it is already built and says how to build it if not. `--engine native` still compiles, because somebody who typed it has asked for the wait. Once it has been run once, auto finds the binary from then on. A timeout is now named as the dialog it is rather than as "probe failed: TimeoutExpired", which is true and useless when the fix is a click. And every one of those reasons reaches `say()`, which writes to a cp437 console - so the em dashes in them were a launch that died on its own explanation. That is how this one was found; there is a test over the module's string literals now so the next one is caught here rather than by a user on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b660441 commit 3cb4003

4 files changed

Lines changed: 120 additions & 5 deletions

File tree

flow/__main__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ def _engine(args, partial_name: str, final_name: str) -> tuple[str, str]:
182182
# auto. Ask the cheap question first: are the models here?
183183
if _models_present(partial_name, final_name):
184184
return "whisper", ""
185-
ok, why = native_available()
185+
# `compile_if_missing=False`, and a short probe. A launch is not the place to
186+
# discover how slow `swiftc` is, and the probe blocks on a permission dialog that
187+
# nobody has been shown yet — measured at a full minute per launch before this.
188+
ok, why = native_available(compile_if_missing=False, timeout=10.0)
186189
if ok:
187190
return "native", " (whisper models not found on this machine)"
188191
return "whisper", f" (not found locally, and no native engine: {why})"

flow/native.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def build(force: bool = False) -> Path:
7979
except (OSError, subprocess.SubprocessError) as exc:
8080
raise NotAvailable(f"no Swift toolchain: {exc}") from exc
8181
if which.returncode != 0:
82-
raise NotAvailable("no Swift toolchain run: xcode-select --install")
82+
raise NotAvailable("no Swift toolchain - run: xcode-select --install")
8383
BUILD_DIR.mkdir(parents=True, exist_ok=True)
8484
try:
8585
# `-parse-as-library` is required, not tuning: a single-file executable is
@@ -96,21 +96,41 @@ def build(force: bool = False) -> Path:
9696
return BINARY
9797

9898

99-
def available() -> tuple[bool, str]:
99+
def available(compile_if_missing: bool = True,
100+
timeout: float = 60.0) -> tuple[bool, str]:
100101
"""`(usable, why not)` for this machine, without starting a session on it.
101102
102103
Runs the helper's own `--probe`, which is the only honest check: the engine exists
103104
when the OS says it does, the locale resolves, on-device recognition is supported —
104105
which needs Dictation enabled so macOS has downloaded the offline model — and the
105106
user has granted the permission. Anything less is a guess that fails later, in the
106107
middle of somebody's first sentence.
108+
109+
**`compile_if_missing=False` is what `--engine auto` uses, and it is not a
110+
micro-optimisation.** Measured in CI: with this asked unconditionally at startup, the
111+
macOS suite went from 35 seconds to 643. Every launch on a Mac without Whisper models
112+
was compiling Swift and then sitting on `--probe` until the timeout, because the
113+
probe waits for an authorization dialog that a headless machine never answers — and a
114+
user's first launch would have done exactly the same thing, for a full minute, before
115+
showing a pill.
116+
117+
So the rule is: **`auto` uses what is ready, and asking for the engine by name is
118+
what builds it.** Once `--engine native` has been run once the binary is there, and
119+
`auto` finds it from then on.
107120
"""
121+
if not compile_if_missing and sys.platform == "darwin" and not BINARY.exists():
122+
return False, ("not built yet - run once with --engine native to compile it")
108123
try:
109124
binary = build()
110125
except NotAvailable as exc:
111126
return False, str(exc)
112127
try:
113-
probe = _run([str(binary), "--probe"], 60.0)
128+
probe = _run([str(binary), "--probe"], timeout)
129+
except subprocess.TimeoutExpired:
130+
# Almost always the permission dialog, unanswered. Named as itself rather than
131+
# as a generic failure, because the fix is a click and the user should hear so.
132+
return False, ("probe timed out - grant Speech Recognition under "
133+
"System Settings > Privacy & Security")
114134
except (OSError, subprocess.SubprocessError) as exc:
115135
return False, f"probe failed: {exc}"
116136
if probe.returncode != 0:

native/flow_stt.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func makeRecognizer() -> SFSpeechRecognizer {
8585
// The whole point. Without this the audio goes to Apple's servers, which is a
8686
// different product from the one Flow is: local by construction.
8787
guard rec.supportsOnDeviceRecognition else {
88-
die("on-device recognition unavailable enable Dictation in System Settings "
88+
die("on-device recognition unavailable - enable Dictation in System Settings "
8989
+ "so macOS downloads the offline model", 5)
9090
}
9191
// Off the main queue, deliberately. `transcribe` waits on a semaphore for the

tests/test_native.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,98 @@ def _engine_result():
119119
return _engine(args(), "base.en", "small.en")
120120

121121

122+
class TestAutoNeverPaysForAnEngineItMayNotUse(unittest.TestCase):
123+
"""The regression CI found, pinned so it cannot come back.
124+
125+
Asking `available()` unconditionally at startup took the macOS CI leg from **35
126+
seconds to 643**. Every launch on a Mac without Whisper models was compiling Swift
127+
and then sitting on `--probe` until it timed out, because the probe waits for an
128+
authorization dialog a headless machine never answers.
129+
130+
A user's first launch would have done the same thing: a full minute of nothing
131+
before a pill appeared, on a machine that had asked for none of it. So `auto` uses
132+
what is *ready*, and naming the engine is what builds it.
133+
"""
134+
135+
def test_auto_will_not_compile_anything(self):
136+
seen = {}
137+
138+
def fake(compile_if_missing=True, timeout=60.0):
139+
seen["compile"] = compile_if_missing
140+
seen["timeout"] = timeout
141+
return False, "not built yet"
142+
143+
with mock.patch.object(sys, "platform", "darwin"), \
144+
mock.patch("flow.__main__._models_present", return_value=False), \
145+
mock.patch.object(native, "available", fake):
146+
_engine(args(), "base.en", "small.en")
147+
self.assertFalse(seen["compile"])
148+
149+
def test_and_will_not_wait_a_minute_on_a_permission_dialog(self):
150+
seen = {}
151+
152+
def fake(compile_if_missing=True, timeout=60.0):
153+
seen["timeout"] = timeout
154+
return False, "not built yet"
155+
156+
with mock.patch.object(sys, "platform", "darwin"), \
157+
mock.patch("flow.__main__._models_present", return_value=False), \
158+
mock.patch.object(native, "available", fake):
159+
_engine(args(), "base.en", "small.en")
160+
self.assertLessEqual(seen["timeout"], 15.0)
161+
162+
def test_naming_the_engine_is_what_builds_it(self):
163+
# The other half of the rule. Somebody who typed `--engine native` has asked for
164+
# the compile and is willing to wait for it.
165+
seen = {}
166+
167+
def fake(compile_if_missing=True, timeout=60.0):
168+
seen["compile"] = compile_if_missing
169+
return True, ""
170+
171+
with mock.patch.object(sys, "platform", "darwin"), \
172+
mock.patch.object(native, "available", fake):
173+
self.assertEqual(_engine(args("native"), "base.en", "small.en")[0],
174+
"native")
175+
self.assertTrue(seen["compile"])
176+
177+
def test_an_unbuilt_helper_says_how_to_build_it(self):
178+
with mock.patch.object(sys, "platform", "darwin"), \
179+
mock.patch.object(Path, "exists", return_value=False):
180+
ok, why = native.available(compile_if_missing=False)
181+
self.assertFalse(ok)
182+
self.assertIn("--engine native", why)
183+
184+
def test_a_probe_that_hangs_is_named_as_the_dialog_it_is(self):
185+
# "probe failed: TimeoutExpired" is true and useless. The fix is a click, and
186+
# the sentence should say so.
187+
with mock.patch.object(sys, "platform", "darwin"), \
188+
mock.patch.object(Path, "exists", return_value=True), \
189+
mock.patch.object(native, "_run",
190+
side_effect=subprocess.TimeoutExpired("probe", 10)):
191+
ok, why = native.available(timeout=10.0)
192+
self.assertFalse(ok)
193+
self.assertIn("Speech Recognition", why)
194+
195+
def test_every_reason_survives_the_console_the_startup_line_prints_to(self):
196+
# `say()` writes to a cp437 console on Windows, and these strings reach it
197+
# through `_engine`. An em dash here is a launch that dies on its own
198+
# explanation — which is exactly how this was found.
199+
for reason in ("not built yet", "no Swift toolchain", "probe timed out"):
200+
with self.subTest(reason=reason):
201+
pass
202+
import inspect
203+
204+
source = inspect.getsource(native)
205+
for line in source.splitlines():
206+
stripped = line.strip()
207+
if stripped.startswith("#") or '"' not in line:
208+
continue
209+
for chunk in line.split('"')[1::2]:
210+
with self.subTest(chunk=chunk[:40]):
211+
chunk.encode("cp437")
212+
213+
122214
class TestTheModelPresenceCheckNeverDownloads(unittest.TestCase):
123215
"""It is asked *because* the network may be unusable; it must not use it."""
124216

0 commit comments

Comments
 (0)