-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(#345): エラーログを DI された ErrorLog 契約に統一する #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import Dependencies | ||
| import Domain | ||
| import ErrorLog | ||
|
|
||
| extension ErrorLogKey: DependencyKey { | ||
| public static let liveValue: any Domain.ErrorLog = StandardErrorLog() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import Dependencies | ||
|
|
||
| /// A write-only, always-on error sink. A caller reports that one operation failed and | ||
| /// says which subsystem it belongs to; the live implementation renders that as a line | ||
| /// on stderr. Like `DeveloperLog` this is a *StandardOutput-family* contract — an | ||
| /// output sink, never a DataStore: nothing reads it back as domain data. | ||
| /// | ||
| /// It is deliberately separate from `DeveloperLog` (config-gated decision traces, file | ||
| /// output, contains listening history) and from `StandardOutput` (CLI results, which a | ||
| /// DataSource has no business reaching for). Sharing either would have made the three | ||
| /// purposes one switch. | ||
| /// | ||
| /// The `lyra: ` prefix, the subsystem rendering, and the trailing newline all belong to | ||
| /// the implementation, so the convention lives in one place instead of being restated | ||
| /// at every call site as it was before #345. | ||
| public protocol ErrorLog: Sendable { | ||
| /// Report one failed operation. `message` describes what failed and why, without | ||
| /// the prefix or the subsystem — `"search failed: \(error)"`, not | ||
| /// `"lyra: MusicBrainz search failed: \(error)"`. | ||
| func record(_ subsystem: ErrorSubsystem, _ message: String) | ||
| } | ||
|
|
||
| public enum ErrorLogKey: TestDependencyKey { | ||
| /// Silent under test: a suite that does not care about error reporting should not | ||
| /// spray stderr, and one that does overrides `$0.errorLog` with a spy. | ||
| public static let testValue: any ErrorLog = SilentErrorLog() | ||
| } | ||
|
|
||
| extension DependencyValues { | ||
| public var errorLog: any ErrorLog { | ||
| get { self[ErrorLogKey.self] } | ||
| set { self[ErrorLogKey.self] = newValue } | ||
| } | ||
| } | ||
|
|
||
| private struct SilentErrorLog: ErrorLog { | ||
| func record(_ subsystem: ErrorSubsystem, _ message: String) {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /// Where an error report came from, as it appears in the daemon's stderr line (#345). | ||
| /// | ||
| /// The set is closed on purpose. Before this existed the subsystem was a bare string | ||
| /// baked into each `fputs` call, so nothing stopped the four sites from disagreeing — | ||
| /// and they did (`LRCLIB` / `MusicBrainz` / `AI` / `spectrum:`, mixing case and an | ||
| /// extra colon). A raw-value enum makes the vocabulary a compile-time fact and gives | ||
| /// the naming rule something to be tested against. | ||
| public enum ErrorSubsystem: String, Sendable, CaseIterable { | ||
| /// The LRCLIB lyrics catalog. | ||
| case lrclib = "LRCLIB" | ||
| /// The MusicBrainz metadata catalog. | ||
| case musicBrainz = "MusicBrainz" | ||
| /// The user-configured OpenAI-compatible metadata extractor. | ||
| case ai = "AI" | ||
| /// Audio capture and analysis for the spectrum overlay. | ||
| case spectrum = "Spectrum" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import Darwin.POSIX | ||
| import Domain | ||
|
|
||
| /// Live `ErrorLog`: one line per report on stderr, which is where all four call sites | ||
| /// were already writing by hand before #345. stderr rather than a file because these | ||
| /// are always-on operational errors — the daemon's stderr is captured by whichever | ||
| /// supervisor started it (brew service, LaunchAgent, or a foreground `lyra daemon`), | ||
| /// so the report lands wherever the user is already looking. | ||
| public struct StandardErrorLog: Sendable { | ||
| private let printer: @Sendable (String) -> Void | ||
|
|
||
| public init() { | ||
| self.init { fputs($0, stderr) } | ||
| } | ||
|
|
||
| /// Test seam, mirroring `PrintStandardOutput`'s injected printers: the rendering is | ||
| /// the part worth asserting on, and it is not observable through a real `fputs`. | ||
| init(printer: @escaping @Sendable (String) -> Void) { | ||
| self.printer = printer | ||
| } | ||
| } | ||
|
|
||
| extension StandardErrorLog: ErrorLog { | ||
| public func record(_ subsystem: ErrorSubsystem, _ message: String) { | ||
| printer("lyra: \(subsystem.rawValue) \(message)\n") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ public final class SpectrumInteractorImpl: @unchecked Sendable { | |
| @Dependency(\.configUseCase) private var configService | ||
| @Dependency(\.playbackUseCase) private var playbackService | ||
| @Dependency(\.spectrumUseCase) private var spectrumService | ||
| @Dependency(\.errorLog) private var errorLog | ||
| private let capturingSubject = CurrentValueSubject<Bool, Never>(false) | ||
| private let processor = OSAllocatedUnfairLock(initialState: Processor.idle) | ||
|
|
||
|
|
@@ -100,13 +101,13 @@ extension SpectrumInteractorImpl: SpectrumInteractor { | |
| failedAttempts = started ? 0 : failedAttempts + 1 | ||
| guard started else { | ||
| let giveUp = failedAttempts >= maxCaptureAttempts | ||
| fputs( | ||
| "lyra: spectrum: startCapture(pid: \(pid)) failed " | ||
| errorLog.record( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a started Useful? React with 👍 / 👎. |
||
| .spectrum, | ||
| "startCapture(pid: \(pid)) failed " | ||
| + "(attempt \(failedAttempts)/\(maxCaptureAttempts)); " | ||
| + (giveUp | ||
| ? "giving up until the source changes\n" | ||
| : "retrying on next now-playing tick\n"), | ||
| stderr) | ||
| ? "giving up until the source changes" | ||
| : "retrying on next now-playing tick")) | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 2.28.5 | ||
| 2.28.6 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import Domain | ||
| import Testing | ||
| import os | ||
|
|
||
| @testable import ErrorLog | ||
|
|
||
| @Suite("StandardErrorLog (#345)") | ||
| struct StandardErrorLogTests { | ||
| @Test("default init() wires the live stderr printer") | ||
| func defaultInitInstantiates() { | ||
| // Exercising init() covers the production wiring; the real fputs is not | ||
| // observable, which is exactly why the printer seam exists. | ||
| _ = StandardErrorLog() | ||
| } | ||
|
|
||
| @Test("a report renders as one prefixed, subsystem-tagged, newline-terminated line") | ||
| func rendersOneLine() { | ||
| let sink = LineRecorder() | ||
|
|
||
| StandardErrorLog(printer: { sink.append($0) }).record(.musicBrainz, "search failed: boom") | ||
|
|
||
| #expect(sink.lines == ["lyra: MusicBrainz search failed: boom\n"]) | ||
| } | ||
|
|
||
| // The point of the contract: the caller hands over the *message*, and the | ||
| // prefix / subsystem / newline convention lives in one place instead of being | ||
| // restated (and mis-stated) at each call site as it was before #345. | ||
| @Test("every subsystem renders the same shape", arguments: ErrorSubsystem.allCases) | ||
| func everySubsystemRendersTheSameShape(subsystem: ErrorSubsystem) { | ||
| let sink = LineRecorder() | ||
|
|
||
| StandardErrorLog(printer: { sink.append($0) }).record(subsystem, "op failed: x") | ||
|
|
||
| #expect(sink.lines == ["lyra: \(subsystem.rawValue) op failed: x\n"]) | ||
| } | ||
|
|
||
| // The naming rule as a test rather than a convention. Before #345 the subsystem | ||
| // was a bare string per call site, and they had already drifted — `spectrum:` | ||
| // carried a lowercase name and an extra colon that the other three did not. | ||
| @Test("subsystem names are uniformly shaped", arguments: ErrorSubsystem.allCases) | ||
| func subsystemNamesAreUniform(subsystem: ErrorSubsystem) { | ||
| let name = subsystem.rawValue | ||
|
|
||
| #expect(!name.isEmpty) | ||
| #expect(name.first?.isUppercase == true) | ||
| #expect(!name.contains(":")) | ||
| #expect(!name.contains(" ")) | ||
| } | ||
|
|
||
| @Test("each report is one call to the sink — nothing is buffered or merged") | ||
| func reportsAreNotBatched() { | ||
| let sink = LineRecorder() | ||
| let log = StandardErrorLog(printer: { sink.append($0) }) | ||
|
|
||
| log.record(.lrclib, "get failed: a") | ||
| log.record(.ai, "extraction failed: b") | ||
|
|
||
| #expect(sink.lines == ["lyra: LRCLIB get failed: a\n", "lyra: AI extraction failed: b\n"]) | ||
| } | ||
| } | ||
|
|
||
| /// Collects what the sink printed. A lock rather than an actor because `record` is | ||
| /// synchronous — an actor could not be read from the nonisolated printer closure. | ||
| private final class LineRecorder: Sendable { | ||
| private let state = OSAllocatedUnfairLock(initialState: [String]()) | ||
|
|
||
| var lines: [String] { state.withLock { $0 } } | ||
|
|
||
| func append(_ line: String) { | ||
| state.withLock { $0.append(line) } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds the
ErrorLogSwiftPM module, butREADME.mdremains unchanged. The repository's module-addition checklist explicitly requires README synchronization alongsidePackage.swift, DI, architecture docs, and AGENTS, so the documented repository shape is incomplete until the new module is reflected there.AGENTS.md reference: AGENTS.md:L207-L212
Useful? React with 👍 / 👎.