Skip to content

Commit d1e1c7f

Browse files
committed
fix(live): make chat download more crash resistant
1 parent cc033b9 commit d1e1c7f

File tree

1 file changed

+227
-52
lines changed

1 file changed

+227
-52
lines changed

internal/exec/twitch.go

Lines changed: 227 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"path/filepath"
910
"syscall"
1011
"time"
1112

@@ -126,88 +127,262 @@ func appendMessageToJSONArray(filename string, comment utils.LiveComment) error
126127
if err != nil {
127128
return fmt.Errorf("failed to stat file: %w", err)
128129
}
129-
size := info.Size()
130130

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 {
134156
return fmt.Errorf("failed to write opening bracket: %w", err)
135157
}
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)
138163
}
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)
141173
}
142-
return f.Sync()
143174
}
144175

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)
151187
}
152188

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)
156191
}
157192

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()
161197
}
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
164212
}
165213

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
169221
}
170222

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
176239
}
177240

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+
}
180249

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
184251
}
185252

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
189271
}
190272

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
195301
}
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
199304
}
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
200316
}
317+
}
201318

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
205322
}
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+
}
208383
}
209384

210-
return f.Sync()
385+
return 0, 0, false, nil
211386
}
212387

213388
// isSpace is sufficient for JSON whitespace around the closing bracket.

0 commit comments

Comments
 (0)