Digital Garden
The launchd Channel and Seventy-One Green Lights
However high the beacon towers are built, someone still has to be willing to run that leg. Some engineering problems get stuck not on wisdom but on channel.
I. Seven features in a day
Seven features landed that day: audio effects extension (three cards for surround, bass boost, and gain, each with a strength slider); the lyric share card (a 900×1620 vertical Canvas, with FileProvider sharing only the cacheDir/lyric_share directory, not the cache root — otherwise other apps could follow the URI into the entire cache); heart mode (preferring unheard, less-heard songs when playing “My Favorites,” stably sorted by ascending play count, no reshuffling at equal heat); “play next” changed to append semantics (three consecutive taps pinned in order behind the current track, self-deduplicating); cold-start optimization; Compose UI regression tests; and crossfade-fade-in.
Two genuine bugs served as specimens inside them. The first version of the lyric share card did nothing on tap — LaunchedEffect(shareLyricCard) reset the state to false at the very start of the coroutine; writing state triggered recomposition, flipping the key from true to false, and LaunchedEffect cancels the previous coroutine when its key changes — the rendering coroutine was killed as soon as it began. Moving the reset to after the entire flow completed, the system share panel popped up properly about twelve seconds later, with no FileUriExposedException anywhere. The speed API had also been chosen at the wrong layer — ultimately replaced with Media3’s playbackParameters.
II. False passes
The seventh item was the lifeline. What regresses most easily in filtering is not filterSongs itself (that has thirteen pure-function cases) but “which index does tapping row 3 actually call” — if the index mapping is wrong, the pure functions all pass green while the real device plays a different song. So Robolectric 4.13 and compose-ui-test-junit4 were introduced, deliberately reversing the list before tapping, with six cases ensuring the mapping was truly tested rather than coincidentally right.
Compilation passed. But the test task never ran to completion: the Mac CI only ran assembleReleaseRenamed and never resolved test dependencies, so dependency errors could not surface in release builds; running it manually on the Mac, Robolectric’s first run needed to download the android-all jar, and the moment ssh dropped, gradle was killed.
One blood lesson deserves recording here: a case once “passed” on the PC — that testDebugUnitTest was judged UP-TO-DATE by Gradle and skipped outright; the five-second build never ran any test at all. A green light is not always a green light; it may simply never have been wired to power.
III. The channel
The master’s single sentence cracked it: “mac certainly has a stable channel that can last fifteen minutes, other projects build for half an hour without trouble.” Following the CI, the engineer found the resident ~/bin/build-worker.py (managed by launchd, com.build.worker.plist) — which is why tasks could run half an hour. The cause of the earlier failures was now clear: nohup dies with the ssh session’s SIGHUP, and macOS has no setsid. The correct answer is launchctl submit — hand the command to launchd, fully detached from the ssh session.
The first true run brought problems to light immediately: fifty-nine passed, five failed, all in the newly added Robolectric UI test class. The root cause was KoinAppAlreadyStartedException — Robolectric instantiates NcmApplication per the manifest, whose onCreate calls startKoin; when the second case ran in the same JVM, Koin was already registered and throwing. The fix was one @After { stopKoin() }, commit cd4fcef.
The second run: seventy-one green lights, zero failures — LrcParseTest 23, BuildOrdersTest 23, SongListFilterTest 13, SongListFilterBarTest 6, CodeErrorTest 6. Without this channel, this batch of tests would never have run; with it, problems surfaced at once. The ~/ncmtest clone, scripts, and launchd task created for testing were all cleaned up, with disk back to 39%.
IV. The honest arithmetic
The cold-start ledger must be reported honestly: diagnostics attributed Koin assembly at 102ms as the largest attributable single block, the root cause being MusicPlayer’s eager property initialization, resolved by Koin at the moment of first-frame composition — equivalent to building an entire ExoPlayer just to glance at the app. After switching to lazy initialization, moving the listener inside the lazy block, and dropping attachEq() from init: Koin assembly 102ms → 84ms, down 18%; total cold-start median 360ms → 361ms, essentially unmoved. The remaining ~270ms sits in the unattributed segment (APK/dex loading plus the first Compose frame), which no small change of this magnitude can shake. The crossfade-fade-in likewise states its boundary plainly: true crossfade requires two overlapping audio streams, impossible with a single ExoPlayer; what was built is “the new song fades in” — a smooth transition, not crossfade in the strict sense. Honest engineering dares to report what was not achieved as not achieved.
Real coordinates
- Seven items: audio effects extension (three cards, three sliders, default 50%); lyric share card (900×1620, FileProvider scope narrowed + read-permission flag, fixed the
LaunchedEffectcoroutine-cancel bug); heart mode (ascending-count stable sort, fallback on mismatched heat tables); “play next” append semantics (pinnedIndicesdeduplicating, carried acrossplayNext, reset instartQueue); cold start; UI regression tests; crossfade-fade-in (honest version) - Test channel: Mac CI never resolves test dependencies;
nohupdies with ssh SIGHUP and macOS has no setsid; switched tolaunchctl submit; first run 59 pass 5 fail (KoinAppAlreadyStartedException— Robolectric instantiatingNcmApplicationdouble-startingstartKoin), fixed with@After { stopKoin() }(cd4fcef), then 71 pass 0 fail - Lesson: a case that “passed” on the PC was actually UP-TO-DATE-skipped; the five-second build ran no tests
- Honest cold-start ledger: Koin 102ms→84ms (-18%), total cold start 360ms→361ms essentially unmoved; remaining ~270ms unattributed (APK/dex + first Compose frame)
- Discipline: all builds and tests went through the Mac only; the
~/ncmtestclone, scripts, and launchd task all cleaned up
Comments