mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
250b5c2c03
* fix(server): an interrupted import rolls back, and its outcome can be asked for (BUG-3475) The web import dialog hung on 'Importing...' when the upload stalled. The server already bounds a stalled body (60s per-Read deadline, BUG-3184), but it then KEPT the partial workspace and said so in a 400 that cannot reach a client whose connection died; a lost 201 read as a failure whose retry made a duplicate. - A transport failure reading the body (deadline, reset, client gone) rolls the partial workspace back and answers 400 import_interrupted with the cause. A mid-stream DATA error still keeps it (TASK-896): its 400 reaches a live client. Told apart by a tracker on the raw body, beneath the size cap: a truncated file that arrives in full reads to a clean EOF. - ?import_key= names an attempt; GET /workspaces/import-status?key= answers running/complete/kept/removed/not_created/unknown for the caller's own key. In memory, 1h TTL, bounded; an unknown key is UNKNOWN, never 'nothing was created'. A running key cannot be reused (409). - The validation-reject rollback and the new one share rollBackPartialImport. The web dialog's progress, stall abort and outcome display follow in PR B. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(server): body-reader census counts the import's transport tracker (BUG-3475) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(mcp): import-status is a known static sibling of /{slug} (BUG-3475) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): a running import key is never evicted, and a panic records unknown (BUG-3475) codex r1: eviction at the bound could drop a RUNNING entry, letting its key begin again beside the upload still using it; it now drops only the oldest settled entry, and an attempt over a bound full of running ones goes untracked (reads unknown). A panic after mint skipped every report and left the key running until the TTL; the handler now records unknown before re-panicking, since the keep door does not say which way it went. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): a running import key outlives the settled TTL (BUG-3475) codex r2: the TTL sweep expired RUNNING entries too, and an import may read for up to its 1h ceiling, so a long upload's key could begin again beside it. Running entries now last importOutcomeRunningTTL (2h), pinned above the read ceiling. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): an import rollback reads back whether the workspace is gone (BUG-3475) codex r3: removeUnusableWorkspace returns nil even when its delete fails, so 'removed' was inferred from a return that does not mean it. rollBackPartialImport now reads the row back; a workspace still live reports unknown and the 400 says it could not be removed. Tested with a trigger refusing the soft-delete, red without the read-back. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
192 lines
6.8 KiB
Go
192 lines
6.8 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// BUG-3475: an import's OUTCOME must reach the person who started it even
|
|
// when the response does not. The web dialog uploads over whatever path the
|
|
// browser has, and on a path that drops packets (Dave's QUIC to Cloudflare)
|
|
// the client gives up on its own timers while the server, 60s later on its
|
|
// per-Read deadline, finishes the import one way or the other. A lost 201 is
|
|
// the same shape: a complete workspace the client reported as failed, and a
|
|
// retry made a duplicate.
|
|
//
|
|
// So the client names each attempt with an import key (?import_key=), the
|
|
// bundle door records what became of it, and GET /workspaces/import-status
|
|
// answers it for the caller who started it.
|
|
//
|
|
// IN MEMORY, by ruling: one instance, a 1h TTL, lost on restart. A key this
|
|
// registry does not hold is an UNKNOWN outcome, never "nothing was created":
|
|
// the client says "check your workspace list" for it.
|
|
|
|
// Import outcome states, as GET /workspaces/import-status reports them.
|
|
const (
|
|
importStateRunning = "running" // the server is still reading or importing
|
|
importStateComplete = "complete" // the workspace was imported (201)
|
|
importStateKept = "kept" // a mid-stream DATA error; the partial workspace was kept and is the caller's
|
|
importStateRemoved = "removed" // a workspace was created and then removed (a reject or an interrupted upload)
|
|
importStateNotCreated = "not_created" // the import failed before any workspace existed
|
|
importStateUnknown = "unknown" // the server could not establish what was left behind
|
|
)
|
|
|
|
const (
|
|
importOutcomeTTL = time.Hour
|
|
// importOutcomeRunningTTL bounds a RUNNING entry: longer than the
|
|
// import's 1h read ceiling plus its work after the last byte.
|
|
importOutcomeRunningTTL = 2 * time.Hour
|
|
// importOutcomeMax bounds the map. A key is minted per attempt by a
|
|
// signed-in user, so this is far above real use; past it the oldest
|
|
// entries go first.
|
|
importOutcomeMax = 10000
|
|
)
|
|
|
|
// importKeyPattern: the client sends a UUID; anything key-shaped and bounded
|
|
// is accepted so a future client is not tied to that spelling.
|
|
var importKeyPattern = regexp.MustCompile(`^[A-Za-z0-9-]{8,64}$`)
|
|
|
|
type importOutcome struct {
|
|
State string `json:"state"`
|
|
WorkspaceSlug string `json:"workspace_slug,omitempty"`
|
|
WorkspaceName string `json:"workspace_name,omitempty"`
|
|
// OwnerUsername is the importer's username, for the client's link: an
|
|
// import's workspace is always owned by the caller who started it.
|
|
OwnerUsername string `json:"owner_username,omitempty"`
|
|
updated time.Time
|
|
}
|
|
|
|
type importOutcomeKey struct{ userID, key string }
|
|
|
|
type importOutcomeRegistry struct {
|
|
mu sync.Mutex
|
|
entries map[importOutcomeKey]*importOutcome
|
|
now func() time.Time
|
|
}
|
|
|
|
func newImportOutcomeRegistry() *importOutcomeRegistry {
|
|
return &importOutcomeRegistry{entries: map[importOutcomeKey]*importOutcome{}, now: time.Now}
|
|
}
|
|
|
|
// begin records a new attempt as running. It refuses a key the same user
|
|
// already has RUNNING: two uploads under one key would race to report.
|
|
func (g *importOutcomeRegistry) begin(userID, key string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.sweepLocked()
|
|
k := importOutcomeKey{userID, key}
|
|
if e, ok := g.entries[k]; ok && e.State == importStateRunning {
|
|
return false
|
|
}
|
|
if len(g.entries) >= importOutcomeMax && !g.evictOldestSettledLocked() {
|
|
// Every entry is a running import (codex r1 on BUG-3475). Evicting
|
|
// one would let its key begin again beside the upload still using
|
|
// it, so this attempt goes untracked instead: its status reads as
|
|
// unknown, which is the truth about it.
|
|
return true
|
|
}
|
|
g.entries[k] = &importOutcome{State: importStateRunning, updated: g.now()}
|
|
return true
|
|
}
|
|
|
|
func (g *importOutcomeRegistry) finish(userID, key, state, slug, name, ownerUsername string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
k := importOutcomeKey{userID, key}
|
|
e, ok := g.entries[k]
|
|
if !ok {
|
|
// Untracked at begin (the map was full of running imports): only an
|
|
// attempt that was tracked has an outcome to report.
|
|
return
|
|
}
|
|
e.State, e.WorkspaceSlug, e.WorkspaceName, e.OwnerUsername, e.updated = state, slug, name, ownerUsername, g.now()
|
|
}
|
|
|
|
// get answers only the user who started the attempt; another user's key is
|
|
// indistinguishable from an unknown one.
|
|
func (g *importOutcomeRegistry) get(userID, key string) (importOutcome, bool) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.sweepLocked()
|
|
e, ok := g.entries[importOutcomeKey{userID, key}]
|
|
if !ok {
|
|
return importOutcome{}, false
|
|
}
|
|
return *e, true
|
|
}
|
|
|
|
func (g *importOutcomeRegistry) sweepLocked() {
|
|
now := g.now()
|
|
cutoff := now.Add(-importOutcomeTTL)
|
|
// A RUNNING entry outlives the TTL (codex r2): an import may read for up
|
|
// to its 1h ceiling (defaultImportReadCeiling) and then still finish, and
|
|
// expiring its key mid-upload would let the key begin again beside it.
|
|
// Every path that returns records an end, so a running entry this old
|
|
// belongs to a request that can no longer finish.
|
|
runningCutoff := now.Add(-importOutcomeRunningTTL)
|
|
for k, e := range g.entries {
|
|
limit := cutoff
|
|
if e.State == importStateRunning {
|
|
limit = runningCutoff
|
|
}
|
|
if e.updated.Before(limit) {
|
|
delete(g.entries, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// evictOldestSettledLocked drops the oldest entry that is NOT running and
|
|
// reports whether there was one. A running entry is never evicted: its key
|
|
// is still in use by an upload, and dropping it would let the key begin again.
|
|
func (g *importOutcomeRegistry) evictOldestSettledLocked() bool {
|
|
var oldest importOutcomeKey
|
|
var at time.Time
|
|
found := false
|
|
for k, e := range g.entries {
|
|
if e.State == importStateRunning {
|
|
continue
|
|
}
|
|
if !found || e.updated.Before(at) {
|
|
oldest, at, found = k, e.updated, true
|
|
}
|
|
}
|
|
if found {
|
|
delete(g.entries, oldest)
|
|
}
|
|
return found
|
|
}
|
|
|
|
func (s *Server) importOutcomesRegistry() *importOutcomeRegistry {
|
|
s.importOutcomesOnce.Do(func() {
|
|
if s.importOutcomes == nil {
|
|
s.importOutcomes = newImportOutcomeRegistry()
|
|
}
|
|
})
|
|
return s.importOutcomes
|
|
}
|
|
|
|
// handleImportStatus answers GET /workspaces/import-status?key=: the outcome
|
|
// of the caller's own import attempt. 404 when this server holds no such key
|
|
// for the caller (never started here, another user's, expired, or lost to a
|
|
// restart): the client reads that as UNKNOWN.
|
|
func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
key := r.URL.Query().Get("key")
|
|
if !importKeyPattern.MatchString(key) {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "key must be 8-64 letters, digits or hyphens")
|
|
return
|
|
}
|
|
e, ok := s.importOutcomesRegistry().get(user.ID, key)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "No import with that key is known to this server")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, e)
|
|
}
|