loader revisited

This commit is contained in:
Ilia Denisov
2026-03-16 19:52:02 +02:00
committed by GitHub
parent e6c6970947
commit 3f1776aa5f
30 changed files with 1581 additions and 527 deletions
+9 -2
View File
@@ -9,6 +9,11 @@ import (
"galaxy/model/report"
)
const (
// ArtifactKindExecutable identifies a downloadable standalone executable artifact.
ArtifactKindExecutable = "executable"
)
// Connector is a main interface to provide connectivity with app's server.
type Connector interface {
UIConnector
@@ -33,10 +38,12 @@ type UIConnector interface {
}
type VersionInfo struct {
OS string `json:"os"` // Operating System name (unix, darwin, windows, etc.)
OS string `json:"os"` // Operating System name (linux, darwin, windows, etc.)
Arch string `json:"arch"` // Target CPU architecture name (amd64, arm64, etc.)
Kind string `json:"kind"` // Artifact kind, currently [ArtifactKindExecutable].
Version string `json:"version"` // Semver format: X.Y.Z
URL string `json:"url"` // Artifact download URL for this version
Checksum SHA256Digest `json:"sha256"` // Base64 SHA-256 checksum for artifact binary data
Checksum SHA256Digest `json:"sha256"` // Hex SHA-256 checksum for artifact binary data
}
// SHA256Digest represents a SHA-256 digest in raw binary form.
+4
View File
@@ -70,6 +70,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) {
original := VersionInfo{
OS: "linux",
Arch: "amd64",
Kind: ArtifactKindExecutable,
Version: "1.2.3",
URL: "http://server:8080",
Checksum: NewSHA256Digest([]byte("payload")),
@@ -83,6 +85,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) {
require.NoError(t, err)
require.Equal(t, original.OS, decoded.OS)
require.Equal(t, original.Arch, decoded.Arch)
require.Equal(t, original.Kind, decoded.Kind)
require.Equal(t, original.Version, decoded.Version)
require.Equal(t, original.URL, decoded.URL)
require.True(t, original.Checksum.Equal(decoded.Checksum))
+63 -10
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"galaxy/connector"
gerr "galaxy/error"
"galaxy/model/client"
"galaxy/model/report"
"io"
@@ -59,7 +60,7 @@ type httpConnector struct {
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
u, err := url.Parse(backendURL)
if err != nil {
return nil, err
return nil, gerr.WrapService(fmt.Errorf("parse backend URL %q: %w", backendURL, err))
}
h := &httpConnector{
ctx: ctx,
@@ -162,6 +163,58 @@ func isConnectTimeout(err error) bool {
return false
}
// isConnectionError reports transport-level connectivity failures that should
// be surfaced as connection errors instead of service contract errors.
func isConnectionError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if isConnectTimeout(err) {
return true
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return false
}
func classifyConnectorError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
if gerr.IsConnection(err) || gerr.IsService(err) {
return err
}
if isConnectionError(err) {
return gerr.WrapConnection(err)
}
return gerr.WrapService(err)
}
// CheckConnection probes backend status endpoint and reports whether server is reachable.
func (h *httpConnector) CheckConnection() bool {
resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
@@ -177,17 +230,17 @@ func (h *httpConnector) CheckConnection() bool {
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
resp, err := h.doRequest(h.requestContext(), checkVersionPath)
if err != nil {
return nil, fmt.Errorf("request versions from backend: %w", err)
return nil, classifyConnectorError(fmt.Errorf("request versions from backend: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode)
return nil, classifyConnectorError(fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode))
}
var versions []connector.VersionInfo
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
return nil, fmt.Errorf("decode versions response: %w", err)
return nil, classifyConnectorError(fmt.Errorf("decode versions response: %w", err))
}
return versions, nil
@@ -198,17 +251,17 @@ func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) {
resp, err := h.doRequest(h.requestContext(), urlOrPath)
if err != nil {
return nil, fmt.Errorf("download version artifact: %w", err)
return nil, classifyConnectorError(fmt.Errorf("download version artifact: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode)
return nil, classifyConnectorError(fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read version artifact body: %w", err)
return nil, classifyConnectorError(fmt.Errorf("read version artifact body: %w", err))
}
return body, nil
@@ -228,17 +281,17 @@ func (h *httpConnector) FetchReport(_ client.GameID, turn uint, callback func(re
func (h *httpConnector) fetchReport(turn uint) (report.Report, error) {
resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn))
if err != nil {
return report.Report{}, fmt.Errorf("request report from backend: %w", err)
return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return report.Report{}, fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode)
return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode))
}
var rep report.Report
if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
return report.Report{}, fmt.Errorf("decode report response: %w", err)
return report.Report{}, classifyConnectorError(fmt.Errorf("decode report response: %w", err))
}
return rep, nil
+64 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"galaxy/connector"
gerr "galaxy/error"
"galaxy/model/report"
"io"
"net"
@@ -151,12 +152,12 @@ func TestCheckVersion(t *testing.T) {
t,
context.Background(),
stdhttp.StatusOK,
`[{"os":"darwin","version":"1.2.3","url":"https://example.com/darwin"}]`,
`[{"os":"darwin","arch":"amd64","kind":"executable","version":"1.2.3","url":"https://example.com/darwin"}]`,
"",
)
},
want: []connector.VersionInfo{
{OS: "darwin", Version: "1.2.3", URL: "https://example.com/darwin"},
{OS: "darwin", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.3", URL: "https://example.com/darwin"},
},
wantPath: "/api/v1/versions",
},
@@ -215,12 +216,12 @@ func TestCheckVersion(t *testing.T) {
t,
context.Background(),
stdhttp.StatusOK,
`[{"os":"linux","version":"2.0.0","url":"https://example.com/linux"}]`,
`[{"os":"linux","arch":"amd64","kind":"executable","version":"2.0.0","url":"https://example.com/linux"}]`,
"/base",
)
},
want: []connector.VersionInfo{
{OS: "linux", Version: "2.0.0", URL: "https://example.com/linux"},
{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "2.0.0", URL: "https://example.com/linux"},
},
wantPath: "/base/api/v1/versions",
},
@@ -260,6 +261,65 @@ func TestCheckVersion(t *testing.T) {
}
}
// TestCheckVersionClassifiesTransportFailure verifies transport failures are surfaced as connection errors.
func TestCheckVersionClassifiesTransportFailure(t *testing.T) {
t.Parallel()
conn := newUnreachableConnector(t, context.Background())
_, err := conn.CheckVersion()
if err == nil {
t.Fatal("CheckVersion() error = nil, want non-nil")
}
if !gerr.IsConnection(err) {
t.Fatalf("CheckVersion() error = %v, want connection classified error", err)
}
}
// TestCheckVersionClassifiesInvalidJSON verifies malformed backend payloads are surfaced as service errors.
func TestCheckVersionClassifiesInvalidJSON(t *testing.T) {
t.Parallel()
conn, _ := newVersionServerConnector(
t,
context.Background(),
stdhttp.StatusOK,
`{"versions":`,
"",
)
_, err := conn.CheckVersion()
if err == nil {
t.Fatal("CheckVersion() error = nil, want non-nil")
}
if !gerr.IsService(err) {
t.Fatalf("CheckVersion() error = %v, want service classified error", err)
}
}
// TestDownloadVersionClassifiesUnexpectedStatus verifies HTTP protocol failures are surfaced as service errors.
func TestDownloadVersionClassifiesUnexpectedStatus(t *testing.T) {
t.Parallel()
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusBadGateway)
}))
t.Cleanup(server.Close)
conn, err := NewHttpConnector(context.Background(), server.URL)
if err != nil {
t.Fatalf("NewHttpConnector() error = %v", err)
}
_, err = conn.DownloadVersion("downloads/client.bin")
if err == nil {
t.Fatal("DownloadVersion() error = nil, want non-nil")
}
if !gerr.IsService(err) {
t.Fatalf("DownloadVersion() error = %v, want service classified error", err)
}
}
// TestFetchReport verifies asynchronous report retrieval behavior.
func TestFetchReport(t *testing.T) {
tests := []fetchReportCase{
+111
View File
@@ -0,0 +1,111 @@
package error
import "errors"
// Class describes a top-level operational error class that can be used by
// higher layers to route failures to the appropriate UI or transport handler.
type Class string
const (
// ClassConnection marks connectivity and transport failures talking to remote services.
ClassConnection Class = "connection"
// ClassStorage marks local persistence and filesystem related failures.
ClassStorage Class = "storage"
// ClassService marks remote service contract and processing failures.
ClassService Class = "service"
)
// ClassifiedError wraps another error with a top-level error class.
//
// The wrapped error remains available through Unwrap so existing callers may
// continue using errors.Is or errors.As against the original cause.
type ClassifiedError struct {
class Class
err error
}
// Error returns either the wrapped error text or, when no wrapped error is available,
// the textual representation of the class itself.
func (e *ClassifiedError) Error() string {
if e == nil {
return ""
}
if e.err != nil {
return e.err.Error()
}
return string(e.class)
}
// Unwrap exposes the wrapped cause for standard Go error inspection.
func (e *ClassifiedError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
// Class returns the top-level class recorded on this error.
func (e *ClassifiedError) Class() Class {
if e == nil {
return ""
}
return e.class
}
// Is reports class equality so errors.Is(err, ErrConnection) style checks remain possible.
func (e *ClassifiedError) Is(target error) bool {
t, ok := target.(*ClassifiedError)
if !ok {
return false
}
return e.class != "" && e.class == t.class
}
var (
// ErrConnection is a class sentinel for connection related failures.
ErrConnection = &ClassifiedError{class: ClassConnection}
// ErrStorage is a class sentinel for storage related failures.
ErrStorage = &ClassifiedError{class: ClassStorage}
// ErrService is a class sentinel for service related failures.
ErrService = &ClassifiedError{class: ClassService}
)
// WrapConnection wraps err with the connection class unless it is already classified.
func WrapConnection(err error) error {
return wrapClass(ClassConnection, err)
}
// WrapStorage wraps err with the storage class unless it is already classified.
func WrapStorage(err error) error {
return wrapClass(ClassStorage, err)
}
// WrapService wraps err with the service class unless it is already classified.
func WrapService(err error) error {
return wrapClass(ClassService, err)
}
// IsConnection reports whether err is classified as a connection failure.
func IsConnection(err error) bool {
return errors.Is(err, ErrConnection)
}
// IsStorage reports whether err is classified as a storage failure.
func IsStorage(err error) bool {
return errors.Is(err, ErrStorage)
}
// IsService reports whether err is classified as a service failure.
func IsService(err error) bool {
return errors.Is(err, ErrService)
}
func wrapClass(class Class, err error) error {
if err == nil {
return nil
}
if existing, ok := errors.AsType[*ClassifiedError](err); ok && existing.class == class {
return err
}
return &ClassifiedError{class: class, err: err}
}
+48
View File
@@ -0,0 +1,48 @@
package error
import (
stderrors "errors"
"testing"
"github.com/stretchr/testify/require"
)
func TestClassifiedErrorWrapPreservesCause(t *testing.T) {
t.Parallel()
cause := stderrors.New("dial tcp: connection refused")
err := WrapConnection(cause)
require.ErrorIs(t, err, cause)
require.True(t, IsConnection(err))
require.False(t, IsStorage(err))
require.False(t, IsService(err))
classified, ok := stderrors.AsType[*ClassifiedError](err)
require.True(t, ok)
require.Equal(t, ClassConnection, classified.Class())
}
func TestClassifiedErrorDoesNotDoubleWrapSameClass(t *testing.T) {
t.Parallel()
cause := stderrors.New("write file")
first := WrapStorage(cause)
second := WrapStorage(first)
require.Same(t, first, second)
}
func TestGenericErrorSupportsAsAndUnwrap(t *testing.T) {
t.Parallel()
cause := stderrors.New("root cause")
err := newGenericError(ErrDummy, cause, "subject")
require.ErrorIs(t, err, cause)
generic, ok := stderrors.AsType[*GenericError](err)
require.True(t, ok)
require.Equal(t, ErrDummy, generic.Code)
require.Equal(t, "Dummy: subject: root cause", err.Error())
}
+6 -1
View File
@@ -192,6 +192,11 @@ func (ge GenericError) Error() string {
return msg
}
// Unwrap returns the underlying error wrapped by GenericError, if any.
func (ge GenericError) Unwrap() error {
return ge.err
}
func newGenericError(code int, arg ...any) error {
e := &GenericError{Code: code}
if len(arg) > 0 {
@@ -207,7 +212,7 @@ func newGenericError(code int, arg ...any) error {
e.subject = asString(arg[i])
}
}
return *e
return e
}
func asString(v any) string {
+1 -1
View File
@@ -1,5 +1,5 @@
package error
func NewRepoError(arg ...any) error {
return newGenericError(ErrStorageFailure, arg...)
return WrapStorage(newGenericError(ErrStorageFailure, arg...))
}
+44 -32
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
gerr "galaxy/error"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
@@ -77,17 +78,17 @@ func NewFS(storageRoot string) (*fsStorage, error) {
fmt.Println("using fs root:", storageRoot)
absRoot, err := filepath.Abs(storageRoot)
if err != nil {
return nil, fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err)
return nil, classifyStorageError(fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err))
}
if ok, err := util.PathExists(absRoot, true); err != nil {
return nil, fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err)
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err))
} else if !ok {
return nil, fmt.Errorf("new fs storage: path %q does not exist", absRoot)
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q does not exist", absRoot))
}
if ok, err := util.Writable(absRoot); err != nil {
return nil, fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err)
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err))
} else if !ok {
return nil, fmt.Errorf("new fs storage: path %q is not writable", absRoot)
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q is not writable", absRoot))
}
return &fsStorage{
@@ -166,7 +167,7 @@ func (s *fsStorage) SaveOrderAsync(id client.GameID, turn uint, o order.Order, c
func (s *fsStorage) FileExists(path string) (bool, string, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return false, "", err
return false, "", classifyStorageError(err)
}
var exists bool
@@ -176,7 +177,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) {
return opErr
})
if err != nil {
return false, "", err
return false, "", classifyStorageError(err)
}
if !exists {
return false, absPath, nil
@@ -187,7 +188,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) {
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return nil, err
return nil, classifyStorageError(err)
}
var data []byte
@@ -196,27 +197,27 @@ func (s *fsStorage) ReadFile(path string) ([]byte, error) {
data, opErr = s.readFileUnlocked(absPath)
return opErr
})
return data, err
return data, classifyStorageError(err)
}
func (s *fsStorage) WriteFile(path string, data []byte) error {
absPath, err := s.resolvePath(path)
if err != nil {
return err
return classifyStorageError(err)
}
return s.withPathLock(absPath, func() error {
return classifyStorageError(s.withPathLock(absPath, func() error {
return s.writeFileUnlocked(absPath, data)
})
}))
}
func (s *fsStorage) DeleteFile(path string) error {
absPath, err := s.resolvePath(path)
if err != nil {
return err
return classifyStorageError(err)
}
return s.withPathLock(absPath, func() error {
return classifyStorageError(s.withPathLock(absPath, func() error {
exists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return err
@@ -228,7 +229,7 @@ func (s *fsStorage) DeleteFile(path string) error {
return fmt.Errorf("delete file %q: %w", absPath, err)
}
return nil
})
}))
}
func (s *fsStorage) ListFiles() ([]string, error) {
@@ -252,7 +253,7 @@ func (s *fsStorage) ListFiles() ([]string, error) {
return nil
})
if err != nil {
return nil, fmt.Errorf("list files under %q: %w", s.storageRoot, err)
return nil, classifyStorageError(fmt.Errorf("list files under %q: %w", s.storageRoot, err))
}
slices.Sort(files)
@@ -261,29 +262,30 @@ func (s *fsStorage) ListFiles() ([]string, error) {
func (s *fsStorage) StateExists() (bool, error) {
exists, _, err := s.FileExists(stateFileName)
return exists, err
return exists, classifyStorageError(err)
}
func (s *fsStorage) LoadState() (client.State, error) {
data, err := s.ReadFile(stateFileName)
if err != nil {
return client.State{}, err
return client.State{}, classifyStorageError(err)
}
return unmarshalState(data)
state, err := unmarshalState(data)
return state, classifyStorageError(err)
}
func (s *fsStorage) SaveState(state client.State) error {
data, err := marshalState(state)
if err != nil {
return err
return classifyStorageError(err)
}
return s.WriteFile(stateFileName, data)
return classifyStorageError(s.WriteFile(stateFileName, data))
}
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return report.Report{}, err
return report.Report{}, classifyStorageError(err)
}
return gameData.Report, nil
}
@@ -291,10 +293,10 @@ func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report,
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return err
return classifyStorageError(err)
}
return s.withPathLock(absPath, func() error {
return classifyStorageError(s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
@@ -306,16 +308,16 @@ func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Repor
gameData.Turn = turn
gameData.Report = rep
return s.writeGameDataUnlocked(absPath, gameData)
})
}))
}
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return order.Order{}, err
return order.Order{}, classifyStorageError(err)
}
if gameData.Order == nil {
return order.Order{}, fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist)
return order.Order{}, classifyStorageError(fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist))
}
return *gameData.Order, nil
}
@@ -323,10 +325,10 @@ func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, err
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return err
return classifyStorageError(err)
}
return s.withPathLock(absPath, func() error {
return classifyStorageError(s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -338,13 +340,13 @@ func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) er
gameData.Turn = turn
gameData.Order = &o
return s.writeGameDataUnlocked(absPath, gameData)
})
}))
}
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return client.GameData{}, err
return client.GameData{}, classifyStorageError(err)
}
var gameData client.GameData
@@ -353,7 +355,7 @@ func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameDa
gameData, opErr = s.loadGameDataUnlocked(absPath)
return opErr
})
return gameData, err
return gameData, classifyStorageError(err)
}
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
@@ -713,3 +715,13 @@ func (s *fsStorage) ensureParentDir(absPath string) error {
}
return nil
}
func classifyStorageError(err error) error {
if err == nil {
return nil
}
if gerr.IsStorage(err) {
return err
}
return gerr.WrapStorage(err)
}
+35
View File
@@ -10,6 +10,7 @@ import (
"testing"
"time"
gerr "galaxy/error"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
@@ -240,10 +241,44 @@ func TestPathTraversalRejected(t *testing.T) {
if err == nil {
t.Fatalf("write %q unexpectedly succeeded", path)
}
if !gerr.IsStorage(err) {
t.Fatalf("write %q error = %v, want storage classified error", path, err)
}
})
}
}
func TestDeleteFileClassifiesAndPreservesNotExist(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
err := s.DeleteFile("missing.txt")
if !gerr.IsStorage(err) {
t.Fatalf("DeleteFile() error = %v, want storage classified error", err)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("DeleteFile() error = %v, want os.ErrNotExist", err)
}
}
func TestLoadStateClassifiesDecodeErrors(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
if err := os.WriteFile(filepath.Join(s.storageRoot, stateFileName), []byte("{"), 0o644); err != nil {
t.Fatalf("seed invalid state file: %v", err)
}
_, err := s.LoadState()
if err == nil {
t.Fatal("LoadState() error = nil, want non-nil")
}
if !gerr.IsStorage(err) {
t.Fatalf("LoadState() error = %v, want storage classified error", err)
}
}
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
s := newTestStorage(t)
target := filepath.Join("turns", "12.bin")