summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadLogging/LogFileOutputStream.swift
blob: a3b7ca4caa6a638443764e42f3fa1df33e3e2b16 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
//
//  LogFileOutputStream.swift
//  MullvadVPN
//
//  Created by pronebird on 02/08/2020.
//  Copyright © 2020 Mullvad VPN AB. All rights reserved.
//

import Foundation
import MullvadTypes

/// Interval used for reopening the log file descriptor in the event of failure to open it in
/// the first place, or when writing to it.
private let reopenFileLogInterval: Duration = .seconds(5)

class LogFileOutputStream: TextOutputStream {
    private let queue = DispatchQueue(label: "LogFileOutputStreamQueue", qos: .utility)

    private let fileURL: URL
    private let encoding: String.Encoding
    private let maxBufferCapacity: Int

    private var state: State = .closed {
        didSet {
            switch (oldValue, state) {
            case (.opened, .waitingToReopen), (.closed, .waitingToReopen):
                startTimer()

            case (.waitingToReopen, .opened), (.waitingToReopen, .closed):
                stopTimer()

            default:
                break
            }
        }
    }

    private var timer: DispatchSourceTimer?
    private var buffer = Data()

    private enum State {
        case closed
        case opened(FileHandle)
        case waitingToReopen
    }

    init(fileURL: URL, encoding: String.Encoding = .utf8, maxBufferCapacity: Int = 16 * 1024) {
        self.fileURL = fileURL
        self.encoding = encoding
        self.maxBufferCapacity = maxBufferCapacity
    }

    deinit {
        stopTimer()
    }

    func write(_ string: String) {
        queue.async {
            self.writeNoQueue(string)
        }
    }

    private func writeNoQueue(_ string: String) {
        guard let data = string.data(using: encoding) else { return }

        switch state {
        case .closed:
            do {
                let fileHandle = try openFile()
                state = .opened(fileHandle)
                try write(fileHandle: fileHandle, data: data)
            } catch {
                bufferData(data)
                state = .waitingToReopen
            }

        case let .opened(fileHandle):
            do {
                try write(fileHandle: fileHandle, data: data)
            } catch {
                bufferData(data)
                state = .waitingToReopen
            }

        case .waitingToReopen:
            bufferData(data)
        }
    }

    @discardableResult private func write(fileHandle: FileHandle, data: Data) throws -> Int {
        let bytesWritten = data.withUnsafeBytes { buffer -> Int in
            guard let ptr = buffer.baseAddress else { return 0 }

            return Darwin.write(fileHandle.fileDescriptor, ptr, buffer.count)
        }

        if bytesWritten == -1 {
            let code = POSIXErrorCode(rawValue: errno)!
            throw POSIXError(code)
        } else {
            return bytesWritten
        }
    }

    private func openFile() throws -> FileHandle {
        let oflag: Int32 = O_WRONLY | O_CREAT
        let mode: mode_t = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH

        let fd = fileURL.path.withCString { Darwin.open($0, oflag, mode) }

        if fd == -1 {
            let code = POSIXErrorCode(rawValue: errno)!
            throw POSIXError(code)
        } else {
            return FileHandle(fileDescriptor: fd, closeOnDealloc: true)
        }
    }

    private func startTimer() {
        timer?.cancel()

        let timer = DispatchSource.makeTimerSource(queue: queue)
        timer.setEventHandler { [weak self] in
            self?.reopenFile()
        }
        timer.schedule(
            wallDeadline: .now() + reopenFileLogInterval,
            repeating: reopenFileLogInterval.timeInterval
        )
        timer.activate()

        self.timer = timer
    }

    private func stopTimer() {
        timer?.cancel()
        timer = nil
    }

    private func reopenFile() {
        do {
            let fileHandle = try openFile()

            // Write a message indicating that the file was reopened.
            let messageData =
                "<Log file re-opened after failure. Buffered \(buffer.count) bytes of messages>\n"
                    .data(using: encoding, allowLossyConversion: true)!
            try write(fileHandle: fileHandle, data: messageData)

            // Write all buffered messages.
            if !buffer.isEmpty {
                try write(fileHandle: fileHandle, data: buffer)
                buffer.removeAll()
            }

            state = .opened(fileHandle)
        } catch {
            state = .waitingToReopen
        }
    }

    private func bufferData(_ data: Data) {
        buffer.append(data)

        if buffer.count > maxBufferCapacity {
            buffer.removeFirst(buffer.count - maxBufferCapacity)
        }
    }
}