Skip to content

Commit b472680

Browse files
committed
fix: tar export error handling, WS goroutine leak, E2E stability
1. Tar export: split into prepareTarExport (can return error response) and streamTarExport (headers committed, errors logged only). Prep errors (bad base64, etc.) now return proper HTTP 500. 2. WS reader goroutine: derive context from caller instead of context.Background() so it's cancelled on syncer shutdown. 3. E2E WebSocket: fire-and-forget the bulk write that triggers the event — prevents unhandled rejection when server shuts down before the response arrives.
1 parent 031f595 commit b472680

File tree

3 files changed

+28
-15
lines changed

3 files changed

+28
-15
lines changed

internal/httpapi/server.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,9 +1436,14 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request, workspaceI
14361436
case "json":
14371437
writeJSON(w, http.StatusOK, visible)
14381438
case "tar":
1439-
if err := s.writeTarExport(w, visible); err != nil {
1440-
log.Printf("tar export error: %v", err)
1441-
writeError(w, http.StatusInternalServerError, "export_error", "tar export failed: "+err.Error(), correlationID)
1439+
prepared, prepErr := s.prepareTarExport(visible)
1440+
if prepErr != nil {
1441+
writeError(w, http.StatusInternalServerError, "export_error", "tar export failed: "+prepErr.Error(), correlationID)
1442+
return
1443+
}
1444+
if err := s.streamTarExport(w, prepared); err != nil {
1445+
// Headers already sent — can only log
1446+
log.Printf("tar export streaming error (headers already sent): %v", err)
14421447
}
14431448
case "patch":
14441449
s.writePatchExport(w, visible)
@@ -2016,17 +2021,20 @@ func stringPropertiesFromAny(values map[string]any) map[string]string {
20162021
return out
20172022
}
20182023

2019-
func (s *Server) writeTarExport(w http.ResponseWriter, files []relayfile.File) error {
2020-
type tarFile struct {
2021-
name string
2022-
modTime time.Time
2023-
content []byte
2024-
}
2024+
type tarFile struct {
2025+
name string
2026+
modTime time.Time
2027+
content []byte
2028+
}
2029+
2030+
// prepareTarExport decodes content and validates all files before any headers are sent.
2031+
// Errors here can still produce a proper HTTP error response.
2032+
func (s *Server) prepareTarExport(files []relayfile.File) ([]tarFile, error) {
20252033
prepared := make([]tarFile, 0, len(files))
20262034
for _, file := range files {
20272035
content, err := decodeExportContent(file)
20282036
if err != nil {
2029-
return err
2037+
return nil, err
20302038
}
20312039
name := strings.TrimPrefix(path.Clean(file.Path), "/")
20322040
if name == "." || name == "" {
@@ -2038,7 +2046,12 @@ func (s *Server) writeTarExport(w http.ResponseWriter, files []relayfile.File) e
20382046
content: content,
20392047
})
20402048
}
2049+
return prepared, nil
2050+
}
20412051

2052+
// streamTarExport writes headers and streams gzip tar data.
2053+
// Once called, HTTP 200 is committed — errors can only be logged, not sent to client.
2054+
func (s *Server) streamTarExport(w http.ResponseWriter, prepared []tarFile) error {
20422055
w.Header().Set("Content-Type", "application/gzip")
20432056
w.Header().Set("Content-Disposition", `attachment; filename="workspace-export.tar.gz"`)
20442057
w.WriteHeader(http.StatusOK)

internal/mountsync/syncer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ func (s *Syncer) connectWebSocket(ctx context.Context) error {
422422
return err
423423
}
424424

425-
readCtx, cancel := context.WithCancel(context.Background())
425+
readCtx, cancel := context.WithCancel(ctx)
426426

427427
s.mu.Lock()
428428
s.wsConn = conn

scripts/e2e.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -537,11 +537,11 @@ ${B}${CYAN}╔══════════════════════
537537
reject(new Error('WebSocket timeout waiting for events'));
538538
}, 10_000);
539539

540-
ws.addEventListener('open', async () => {
541-
// Write a file to trigger an event
542-
await api('POST', `/v1/workspaces/${WORKSPACE}/fs/bulk`, {
540+
ws.addEventListener('open', () => {
541+
// Write a file to trigger an event (fire-and-forget, ignore errors from server shutdown)
542+
api('POST', `/v1/workspaces/${WORKSPACE}/fs/bulk`, {
543543
files: [{ path: '/ws-test.txt', content: 'websocket test' }],
544-
});
544+
}).catch(() => {});
545545
});
546546

547547
ws.addEventListener('message', (event) => {

0 commit comments

Comments
 (0)