mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
ad6cfc2c5e
* fix(cli): a bundle import shows progress, abandons a stalled upload, and resolves a lost answer (BUG-3476) `pad workspace import <bundle>` printed nothing until the server answered, sat silent on a dead TCP path until the kernel gave up or the 1h client cap fired, and when the answer was lost could not say whether the workspace existed, so a re-run could duplicate it. - Each bundle import names its attempt with an import key (UUID), which the bundle door records and GET /workspaces/import-status answers (BUG-3475). - Progress to stderr every 5s: bytes sent / total and percent, updated in place on a TTY. - A stall (the transport takes no bytes for 90s; the server's per-Read deadline is 60s) abandons the upload. - After the last byte: "Upload complete; the server is importing", then import-status every 10s. A settled outcome seen twice while the answer has not arrived ends the wait: the answer was lost. - After any failure that is not the server's own JSON answer, it asks the key (waiting up to 90s for a running attempt to settle) and reports: complete -> success with the slug; kept -> the partial workspace named; removed / not_created -> safe to re-run; no record or unknown -> "outcome unknown: check 'pad workspace list' before re-running, a re-run may create a duplicate". An older server ignores the key and lands in that honest unknown branch. The legacy JSON import is unchanged (its route takes no key). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(cli): the stall message says what the counter knows (BUG-3476 codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// BUG-3476: `pad workspace import` of a bundle printed nothing until the
|
|
// response, could sit silent for minutes on a dead TCP path (a blocked body
|
|
// write fails only at the kernel's retransmit limit), and when the answer was
|
|
// lost it could not say whether the workspace exists. These are the pieces the
|
|
// command watches the upload with: a reader that counts what the transport has
|
|
// taken, a context-aware upload, and the outcome lookup BUG-3475 added.
|
|
|
|
// CountingReader counts the bytes read through it and when the last read
|
|
// happened. The HTTP transport reads the body only as the connection accepts
|
|
// it, so a read that stops arriving is a stalled upload.
|
|
type CountingReader struct {
|
|
r io.Reader
|
|
n atomic.Int64
|
|
lastRead atomic.Int64 // UnixNano of the latest read that returned bytes
|
|
}
|
|
|
|
// NewCountingReader wraps r. The stall clock starts now, so a connection that
|
|
// never starts reading counts as stalled too.
|
|
func NewCountingReader(r io.Reader) *CountingReader {
|
|
c := &CountingReader{r: r}
|
|
c.lastRead.Store(time.Now().UnixNano())
|
|
return c
|
|
}
|
|
|
|
func (c *CountingReader) Read(p []byte) (int, error) {
|
|
n, err := c.r.Read(p)
|
|
if n > 0 {
|
|
c.n.Add(int64(n))
|
|
c.lastRead.Store(time.Now().UnixNano())
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// Sent is how many bytes the transport has taken.
|
|
func (c *CountingReader) Sent() int64 { return c.n.Load() }
|
|
|
|
// SinceLastRead is how long ago the transport last took bytes.
|
|
func (c *CountingReader) SinceLastRead() time.Duration {
|
|
return time.Since(time.Unix(0, c.lastRead.Load()))
|
|
}
|
|
|
|
// PostStreamContext is PostStreamWithContentTypeHeaders under a context, so a
|
|
// caller watching the upload can abandon it.
|
|
func (c *Client) PostStreamContext(ctx context.Context, path string, body io.Reader, contentType string, result interface{}) (http.Header, error) {
|
|
req, err := c.newRequest("POST", path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header.Set("Content-Type", contentType)
|
|
resp, err := c.streamClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
header := resp.Header
|
|
return header, c.handleResponse(resp, result)
|
|
}
|
|
|
|
// Import outcome states, as GET /workspaces/import-status reports them
|
|
// (BUG-3475, internal/server/import_outcomes.go).
|
|
const (
|
|
ImportStateRunning = "running"
|
|
ImportStateComplete = "complete"
|
|
ImportStateKept = "kept"
|
|
ImportStateRemoved = "removed"
|
|
ImportStateNotCreated = "not_created"
|
|
ImportStateUnknown = "unknown"
|
|
)
|
|
|
|
// ImportStatus is what the server knows about one import attempt.
|
|
type ImportStatus struct {
|
|
State string `json:"state"`
|
|
WorkspaceSlug string `json:"workspace_slug,omitempty"`
|
|
WorkspaceName string `json:"workspace_name,omitempty"`
|
|
OwnerUsername string `json:"owner_username,omitempty"`
|
|
}
|
|
|
|
// ErrImportStatusUnknown is the server holding no record of the key: never
|
|
// started there, expired, lost to a restart, or a server older than BUG-3475,
|
|
// which ignores the key. It is an UNKNOWN outcome, never "nothing created".
|
|
var ErrImportStatusUnknown = errors.New("the server holds no record of this import")
|
|
|
|
// GetImportStatus asks what became of the caller's import attempt with key.
|
|
func (c *Client) GetImportStatus(key string) (*ImportStatus, error) {
|
|
var st ImportStatus
|
|
if err := c.get("/workspaces/import-status?key="+url.QueryEscape(key), &st); err != nil {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == "not_found" {
|
|
return nil, ErrImportStatusUnknown
|
|
}
|
|
return nil, err
|
|
}
|
|
return &st, nil
|
|
}
|