| 1 | package repair |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | filelock "reasonix/internal/identitylock" |
| 11 | ) |
| 12 | |
| 13 | // StartupState is the legacy startup-state.json shape written by v1.18-v1.19. |
| 14 | // v1.20 reads it only to attach bounded diagnostics to the next crash report; |
| 15 | // it never writes the record or uses it to select a launch mode. |
| 16 | type StartupState struct { |
| 17 | SchemaVersion int `json:"schemaVersion"` |
| 18 | Phase string `json:"phase"` |
| 19 | Version string `json:"version,omitempty"` |
| 20 | InstallProfile string `json:"installProfile,omitempty"` |
| 21 | UpdateFromVersion string `json:"updateFromVersion,omitempty"` |
| 22 | UpdateToVersion string `json:"updateToVersion,omitempty"` |
| 23 | PID int `json:"pid,omitempty"` |
| 24 | StartedAt string `json:"startedAt,omitempty"` |
| 25 | UpdatedAt string `json:"updatedAt,omitempty"` |
| 26 | } |
| 27 | |
| 28 | // PreviousRunObservation is a privacy-safe description of a startup record |
| 29 | // whose owner is no longer alive. PID and filesystem paths remain local. |
| 30 | type PreviousRunObservation struct { |
| 31 | Abnormal bool |
| 32 | Phase string |
| 33 | Version string |
| 34 | InstallProfile string |
| 35 | UpdateFrom string |
| 36 | UpdateTo string |
| 37 | UptimeBucket string |
| 38 | } |
| 39 | |
| 40 | // StartupTracker is a one-shot adapter for legacy startup records. |
| 41 | type StartupTracker struct { |
| 42 | path string |
| 43 | processAlive func(int) bool |
| 44 | } |
| 45 | |
| 46 | func NewStartupTracker(path string) *StartupTracker { |
| 47 | if path == "" { |
| 48 | if root := config.MemoryUserDir(); root != "" { |
| 49 | path = filepath.Join(root, "repair", "startup-state.json") |
| 50 | } |
| 51 | } |
| 52 | return &StartupTracker{path: path, processAlive: startupProcessAlive} |
| 53 | } |
| 54 | |
| 55 | func (t *StartupTracker) Read() (StartupState, error) { |
| 56 | return readStartupState(t.path) |
| 57 | } |
| 58 | |
| 59 | func readStartupState(path string) (StartupState, error) { |
| 60 | if path == "" { |
| 61 | return StartupState{}, nil |
| 62 | } |
| 63 | b, err := os.ReadFile(path) |
| 64 | if err != nil { |
| 65 | if os.IsNotExist(err) { |
| 66 | return StartupState{}, nil |
| 67 | } |
| 68 | return StartupState{}, err |
| 69 | } |
| 70 | var state StartupState |
| 71 | if err := json.Unmarshal(b, &state); err != nil { |
| 72 | return StartupState{}, err |
| 73 | } |
| 74 | return state, nil |
| 75 | } |
| 76 | |
| 77 | // ObservePreviousRun atomically claims a completed legacy record and reports |
| 78 | // an unclean prior process at most once. A record owned by a live legacy |
| 79 | // process is never touched, and no observation can alter startup behavior. |
| 80 | func (t *StartupTracker) ObservePreviousRun() PreviousRunObservation { |
| 81 | if t.path == "" { |
| 82 | return PreviousRunObservation{} |
| 83 | } |
| 84 | release, err := filelock.TryAcquire(t.path + ".claim.lock") |
| 85 | if err != nil { |
| 86 | return PreviousRunObservation{} |
| 87 | } |
| 88 | defer release() |
| 89 | |
| 90 | state, err := t.Read() |
| 91 | if err != nil || state.Phase == "" { |
| 92 | return PreviousRunObservation{} |
| 93 | } |
| 94 | if runningStartupPhase(state.Phase) && state.PID > 0 && t.processAlive(state.PID) { |
| 95 | return PreviousRunObservation{} |
| 96 | } |
| 97 | |
| 98 | claimed := t.path + ".claimed-" + time.Now().UTC().Format("20060102T150405.000000000") |
| 99 | if err := os.Rename(t.path, claimed); err != nil { |
| 100 | // Another launch may already have claimed the same record. |
| 101 | return PreviousRunObservation{} |
| 102 | } |
| 103 | defer os.Remove(claimed) |
| 104 | |
| 105 | // Re-read the claimed bytes so a legacy writer that completed between the |
| 106 | // initial read and rename cannot be misclassified from a stale snapshot. |
| 107 | state, err = readStartupState(claimed) |
| 108 | if err != nil || state.Phase == "" { |
| 109 | // A legacy owner may still have been replacing the file while this old |
| 110 | // format was claimed. Preserve ambiguous bytes instead of turning a |
| 111 | // partial write into evidence loss. |
| 112 | _ = os.Rename(claimed, t.path) |
| 113 | return PreviousRunObservation{} |
| 114 | } |
| 115 | if state.Phase == "clean-exit" { |
| 116 | return PreviousRunObservation{} |
| 117 | } |
| 118 | if runningStartupPhase(state.Phase) && state.PID > 0 && t.processAlive(state.PID) { |
| 119 | // This is only possible if the legacy owner changed state during the |
| 120 | // claim window. Restore its record when the original path is still free. |
| 121 | _ = os.Rename(claimed, t.path) |
| 122 | return PreviousRunObservation{} |
| 123 | } |
| 124 | return PreviousRunObservation{ |
| 125 | Abnormal: true, |
| 126 | Phase: state.Phase, |
| 127 | Version: state.Version, |
| 128 | InstallProfile: state.InstallProfile, |
| 129 | UpdateFrom: state.UpdateFromVersion, |
| 130 | UpdateTo: state.UpdateToVersion, |
| 131 | UptimeBucket: startupUptimeBucket(state), |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func startupUptimeBucket(state StartupState) string { |
| 136 | started, startErr := time.Parse(time.RFC3339Nano, state.StartedAt) |
| 137 | updated, updateErr := time.Parse(time.RFC3339Nano, state.UpdatedAt) |
| 138 | if startErr != nil || updateErr != nil || updated.Before(started) { |
| 139 | return "unknown" |
| 140 | } |
| 141 | switch d := updated.Sub(started); { |
| 142 | case d < 30*time.Second: |
| 143 | return "s_0_30" |
| 144 | case d < 2*time.Minute: |
| 145 | return "m_0_2" |
| 146 | case d < 10*time.Minute: |
| 147 | return "m_2_10" |
| 148 | case d < time.Hour: |
| 149 | return "m_10_60" |
| 150 | case d < 6*time.Hour: |
| 151 | return "h_1_6" |
| 152 | default: |
| 153 | return "h_6_plus" |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func runningStartupPhase(phase string) bool { |
| 158 | return phase == "starting" || phase == "ready" || phase == "healthy" |
| 159 | } |
| 160 |