|
6 | 6 | "fmt" |
7 | 7 | "io" |
8 | 8 | "os" |
| 9 | + "path/filepath" |
9 | 10 | "syscall" |
10 | 11 | "time" |
11 | 12 |
|
@@ -126,88 +127,262 @@ func appendMessageToJSONArray(filename string, comment utils.LiveComment) error |
126 | 127 | if err != nil { |
127 | 128 | return fmt.Errorf("failed to stat file: %w", err) |
128 | 129 | } |
129 | | - size := info.Size() |
130 | 130 |
|
131 | | - // new file - write a complete array with a single element. |
132 | | - if size == 0 { |
133 | | - if _, err := f.Write([]byte("[\n")); err != nil { |
| 131 | + copyFrom, copyPrefixUntil, isEmptyArray, needsOpeningBracket, err := inspectJSONArrayPrefix(f, info.Size()) |
| 132 | + if err != nil { |
| 133 | + return fmt.Errorf("failed to inspect existing JSON array: %w", err) |
| 134 | + } |
| 135 | + |
| 136 | + // Write to a temp file and atomically rename it over the original. |
| 137 | + // This avoids leaving a partially written/truncated JSON file on crashes. |
| 138 | + dir := filepath.Dir(filename) |
| 139 | + base := filepath.Base(filename) |
| 140 | + tmp, err := os.CreateTemp(dir, base+".tmp-*") |
| 141 | + if err != nil { |
| 142 | + return fmt.Errorf("failed to create temp file: %w", err) |
| 143 | + } |
| 144 | + tmpName := tmp.Name() |
| 145 | + defer func() { |
| 146 | + _ = tmp.Close() |
| 147 | + _ = os.Remove(tmpName) |
| 148 | + }() |
| 149 | + |
| 150 | + if _, err := f.Seek(copyFrom, io.SeekStart); err != nil { |
| 151 | + return fmt.Errorf("failed to seek original file: %w", err) |
| 152 | + } |
| 153 | + |
| 154 | + if needsOpeningBracket { |
| 155 | + if _, err := tmp.Write([]byte("[\n")); err != nil { |
134 | 156 | return fmt.Errorf("failed to write opening bracket: %w", err) |
135 | 157 | } |
136 | | - if _, err := f.Write(msg); err != nil { |
137 | | - return fmt.Errorf("failed to write message: %w", err) |
| 158 | + } |
| 159 | + |
| 160 | + if copyPrefixUntil > copyFrom { |
| 161 | + if _, err := io.CopyN(tmp, f, copyPrefixUntil-copyFrom); err != nil { |
| 162 | + return fmt.Errorf("failed to copy existing JSON prefix: %w", err) |
138 | 163 | } |
139 | | - if _, err := f.Write([]byte("\n]\n")); err != nil { |
140 | | - return fmt.Errorf("failed to write closing bracket: %w", err) |
| 164 | + } |
| 165 | + |
| 166 | + if isEmptyArray { |
| 167 | + if _, err := tmp.Write([]byte("\n")); err != nil { |
| 168 | + return fmt.Errorf("failed to write newline: %w", err) |
| 169 | + } |
| 170 | + } else { |
| 171 | + if _, err := tmp.Write([]byte(",\n")); err != nil { |
| 172 | + return fmt.Errorf("failed to write message separator: %w", err) |
141 | 173 | } |
142 | | - return f.Sync() |
143 | 174 | } |
144 | 175 |
|
145 | | - // Read only a small tail of the file to find the closing ']' and |
146 | | - // determine whether the array is empty or not. |
147 | | - const tailSize = 1024 |
148 | | - bufSize := size |
149 | | - if bufSize > tailSize { |
150 | | - bufSize = tailSize |
| 176 | + if _, err := tmp.Write(msg); err != nil { |
| 177 | + return fmt.Errorf("failed to write chat message: %w", err) |
| 178 | + } |
| 179 | + if _, err := tmp.Write([]byte("\n]\n")); err != nil { |
| 180 | + return fmt.Errorf("failed to write closing bracket: %w", err) |
| 181 | + } |
| 182 | + if err := tmp.Sync(); err != nil { |
| 183 | + return fmt.Errorf("failed to sync temp file: %w", err) |
| 184 | + } |
| 185 | + if err := tmp.Close(); err != nil { |
| 186 | + return fmt.Errorf("failed to close temp file: %w", err) |
151 | 187 | } |
152 | 188 |
|
153 | | - buf := make([]byte, bufSize) |
154 | | - if _, err := f.ReadAt(buf, size-bufSize); err != nil && err != io.EOF { |
155 | | - return fmt.Errorf("failed to read file tail: %w", err) |
| 189 | + if err := os.Rename(tmpName, filename); err != nil { |
| 190 | + return fmt.Errorf("failed to atomically replace file: %w", err) |
156 | 191 | } |
157 | 192 |
|
158 | | - // Find last non-whitespace char (should be ']'). |
159 | | - i := int(bufSize - 1) |
160 | | - for ; i >= 0 && isSpace(buf[i]); i-- { |
| 193 | + // Best-effort sync of parent directory to increase rename durability. |
| 194 | + if dirF, err := os.Open(dir); err == nil { |
| 195 | + _ = dirF.Sync() |
| 196 | + _ = dirF.Close() |
161 | 197 | } |
162 | | - if i < 0 || buf[i] != ']' { |
163 | | - return fmt.Errorf("file %s is not a JSON array (missing closing ])", filename) |
| 198 | + |
| 199 | + return nil |
| 200 | +} |
| 201 | + |
| 202 | +// inspectJSONArrayPrefix inspects the existing file and returns: |
| 203 | +// - copyPrefixUntil: number of bytes from the beginning to copy into the temp file |
| 204 | +// before appending the next message |
| 205 | +// - isEmptyArray: whether the array currently has zero elements |
| 206 | +// |
| 207 | +// It supports recovery from interrupted writes where trailing commas and/or the |
| 208 | +// closing bracket are missing. |
| 209 | +func inspectJSONArrayPrefix(f *os.File, size int64) (copyFrom, copyPrefixUntil int64, isEmptyArray, needsOpeningBracket bool, err error) { |
| 210 | + if size == 0 { |
| 211 | + return 0, 0, true, true, nil |
164 | 212 | } |
165 | 213 |
|
166 | | - // Look backwards to see what’s before the closing ']' to check if array is empty. |
167 | | - j := i - 1 |
168 | | - for ; j >= 0 && isSpace(buf[j]); j-- { |
| 214 | + firstIdx, firstByte, ok, err := findFirstNonSpaceInRange(f, 0, size) |
| 215 | + if err != nil { |
| 216 | + return 0, 0, false, false, err |
| 217 | + } |
| 218 | + if !ok { |
| 219 | + // File contains only whitespace; treat as empty/repairable. |
| 220 | + return 0, 0, true, true, nil |
169 | 221 | } |
170 | 222 |
|
171 | | - isEmptyArray := false |
172 | | - if j >= 0 && buf[j] == '[' { |
173 | | - isEmptyArray = true |
174 | | - } else if size <= 2 { |
175 | | - isEmptyArray = true |
| 223 | + if firstByte != '[' { |
| 224 | + // Recovery path: handle files missing the opening '[' due to prior |
| 225 | + // interrupted/broken writes. |
| 226 | + copyUntil, empty, recErr := inspectMissingOpeningBracketPrefix(f, firstIdx, size) |
| 227 | + if recErr != nil { |
| 228 | + return 0, 0, false, false, recErr |
| 229 | + } |
| 230 | + return firstIdx, copyUntil, empty, true, nil |
| 231 | + } |
| 232 | + |
| 233 | + lastIdx, lastByte, ok, err := findLastNonSpaceBefore(f, size) |
| 234 | + if err != nil { |
| 235 | + return 0, 0, false, false, err |
| 236 | + } |
| 237 | + if !ok { |
| 238 | + return 0, 0, true, true, nil |
176 | 239 | } |
177 | 240 |
|
178 | | - // Compute the absolute offset of the closing ']' in the file. |
179 | | - lastBracketOffset := (size - bufSize) + int64(i) |
| 241 | + if lastByte == ']' { |
| 242 | + prevIdx, prevByte, ok, err := findLastNonSpaceBefore(f, lastIdx) |
| 243 | + if err != nil { |
| 244 | + return 0, 0, false, false, err |
| 245 | + } |
| 246 | + if !ok { |
| 247 | + return 0, 0, false, false, fmt.Errorf("malformed JSON array") |
| 248 | + } |
180 | 249 |
|
181 | | - // Drop the closing ']' (and any trailing whitespace after it). |
182 | | - if err := f.Truncate(lastBracketOffset); err != nil { |
183 | | - return fmt.Errorf("failed to truncate file: %w", err) |
| 250 | + return 0, lastIdx, prevIdx == firstIdx && prevByte == '[', false, nil |
184 | 251 | } |
185 | 252 |
|
186 | | - // Seek to the end after truncation. |
187 | | - if _, err := f.Seek(0, io.SeekEnd); err != nil { |
188 | | - return fmt.Errorf("failed to seek: %w", err) |
| 253 | + // Recovery path: file likely ended mid-write. Trim trailing commas/whitespace. |
| 254 | + searchEnd := size |
| 255 | + for { |
| 256 | + idx, b, found, err := findLastNonSpaceBefore(f, searchEnd) |
| 257 | + if err != nil { |
| 258 | + return 0, 0, false, false, err |
| 259 | + } |
| 260 | + if !found { |
| 261 | + return 0, firstIdx + 1, true, false, nil |
| 262 | + } |
| 263 | + |
| 264 | + if b == ',' { |
| 265 | + searchEnd = idx |
| 266 | + continue |
| 267 | + } |
| 268 | + |
| 269 | + copyPrefixUntil = idx + 1 |
| 270 | + break |
189 | 271 | } |
190 | 272 |
|
191 | | - // If the array already has elements, add a comma; otherwise just a newline. |
192 | | - if isEmptyArray { |
193 | | - if _, err := f.Write([]byte("\n")); err != nil { |
194 | | - return fmt.Errorf("failed to write newline: %w", err) |
| 273 | + if copyPrefixUntil <= firstIdx { |
| 274 | + return 0, firstIdx + 1, true, false, nil |
| 275 | + } |
| 276 | + |
| 277 | + _, _, hasContentAfterOpenBracket, err := findFirstNonSpaceInRange(f, firstIdx+1, copyPrefixUntil) |
| 278 | + if err != nil { |
| 279 | + return 0, 0, false, false, err |
| 280 | + } |
| 281 | + |
| 282 | + return 0, copyPrefixUntil, !hasContentAfterOpenBracket, false, nil |
| 283 | +} |
| 284 | + |
| 285 | +func inspectMissingOpeningBracketPrefix(f *os.File, firstIdx, size int64) (copyPrefixUntil int64, isEmptyArray bool, err error) { |
| 286 | + searchEnd := size |
| 287 | + |
| 288 | + // If a trailing closing bracket exists, drop it first. |
| 289 | + if idx, b, found, err := findLastNonSpaceBefore(f, searchEnd); err != nil { |
| 290 | + return 0, false, err |
| 291 | + } else if !found { |
| 292 | + return firstIdx, true, nil |
| 293 | + } else if b == ']' { |
| 294 | + searchEnd = idx |
| 295 | + } |
| 296 | + |
| 297 | + for { |
| 298 | + idx, b, found, err := findLastNonSpaceBefore(f, searchEnd) |
| 299 | + if err != nil { |
| 300 | + return 0, false, err |
195 | 301 | } |
196 | | - } else { |
197 | | - if _, err := f.Write([]byte(",\n")); err != nil { |
198 | | - return fmt.Errorf("failed to write comma: %w", err) |
| 302 | + if !found { |
| 303 | + return firstIdx, true, nil |
199 | 304 | } |
| 305 | + |
| 306 | + if b == ',' { |
| 307 | + searchEnd = idx |
| 308 | + continue |
| 309 | + } |
| 310 | + |
| 311 | + if idx < firstIdx { |
| 312 | + return firstIdx, true, nil |
| 313 | + } |
| 314 | + |
| 315 | + return idx + 1, false, nil |
200 | 316 | } |
| 317 | +} |
201 | 318 |
|
202 | | - // Write the new message and close the array again. |
203 | | - if _, err := f.Write(msg); err != nil { |
204 | | - return fmt.Errorf("failed to write message: %w", err) |
| 319 | +func findFirstNonSpaceInRange(f *os.File, start, end int64) (int64, byte, bool, error) { |
| 320 | + if start >= end { |
| 321 | + return 0, 0, false, nil |
205 | 322 | } |
206 | | - if _, err := f.Write([]byte("\n]\n")); err != nil { |
207 | | - return fmt.Errorf("failed to write closing bracket: %w", err) |
| 323 | + |
| 324 | + const chunkSize int64 = 4096 |
| 325 | + buf := make([]byte, chunkSize) |
| 326 | + |
| 327 | + for offset := start; offset < end; { |
| 328 | + toRead := end - offset |
| 329 | + if toRead > chunkSize { |
| 330 | + toRead = chunkSize |
| 331 | + } |
| 332 | + |
| 333 | + n, err := f.ReadAt(buf[:toRead], offset) |
| 334 | + if err != nil && err != io.EOF { |
| 335 | + return 0, 0, false, err |
| 336 | + } |
| 337 | + |
| 338 | + for i := 0; i < n; i++ { |
| 339 | + if !isSpace(buf[i]) { |
| 340 | + return offset + int64(i), buf[i], true, nil |
| 341 | + } |
| 342 | + } |
| 343 | + |
| 344 | + offset += int64(n) |
| 345 | + if n == 0 { |
| 346 | + break |
| 347 | + } |
| 348 | + } |
| 349 | + |
| 350 | + return 0, 0, false, nil |
| 351 | +} |
| 352 | + |
| 353 | +func findLastNonSpaceBefore(f *os.File, end int64) (int64, byte, bool, error) { |
| 354 | + if end <= 0 { |
| 355 | + return 0, 0, false, nil |
| 356 | + } |
| 357 | + |
| 358 | + const chunkSize int64 = 4096 |
| 359 | + buf := make([]byte, chunkSize) |
| 360 | + |
| 361 | + for right := end; right > 0; { |
| 362 | + left := right - chunkSize |
| 363 | + if left < 0 { |
| 364 | + left = 0 |
| 365 | + } |
| 366 | + |
| 367 | + toRead := right - left |
| 368 | + n, err := f.ReadAt(buf[:toRead], left) |
| 369 | + if err != nil && err != io.EOF { |
| 370 | + return 0, 0, false, err |
| 371 | + } |
| 372 | + |
| 373 | + for i := n - 1; i >= 0; i-- { |
| 374 | + if !isSpace(buf[i]) { |
| 375 | + return left + int64(i), buf[i], true, nil |
| 376 | + } |
| 377 | + } |
| 378 | + |
| 379 | + right = left |
| 380 | + if n == 0 { |
| 381 | + break |
| 382 | + } |
208 | 383 | } |
209 | 384 |
|
210 | | - return f.Sync() |
| 385 | + return 0, 0, false, nil |
211 | 386 | } |
212 | 387 |
|
213 | 388 | // isSpace is sufficient for JSON whitespace around the closing bracket. |
|
0 commit comments