| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether'; |
| b69ab31 | | | 9 | import type {Operation} from './operations/Operation'; |
| b69ab31 | | | 10 | import type {Disposable, Hash, ProgressStep, ServerToClientMessage} from './types'; |
| b69ab31 | | | 11 | |
| b69ab31 | | | 12 | import {atom} from 'jotai'; |
| b69ab31 | | | 13 | import {useCallback} from 'react'; |
| b69ab31 | | | 14 | import {defer} from 'shared/utils'; |
| b69ab31 | | | 15 | import serverAPI from './ClientToServerAPI'; |
| b69ab31 | | | 16 | import {atomFamilyWeak, readAtom, writeAtom} from './jotaiUtils'; |
| b69ab31 | | | 17 | import {atomResetOnCwdChange} from './repositoryData'; |
| 4bb999b | | | 18 | import {applicationinfo} from './serverAPIState'; |
| b69ab31 | | | 19 | import {Timer} from './timer'; |
| b69ab31 | | | 20 | import {registerCleanup, registerDisposable, short} from './utils'; |
| b69ab31 | | | 21 | |
| b69ab31 | | | 22 | export type OperationInfo = { |
| b69ab31 | | | 23 | operation: Operation; |
| b69ab31 | | | 24 | startTime?: Date; |
| b69ab31 | | | 25 | commandOutput?: Array<string>; |
| b69ab31 | | | 26 | currentProgress?: ProgressStep; |
| b69ab31 | | | 27 | /** progress message shown next to a commit */ |
| b69ab31 | | | 28 | inlineProgress?: Map<Hash, string>; |
| b69ab31 | | | 29 | /** if true, we have sent "abort" request, the process might have exited or is going to exit soon */ |
| b69ab31 | | | 30 | aborting?: boolean; |
| b69ab31 | | | 31 | /** if true, the operation process has exited AND there's no more optimistic commit state to show */ |
| b69ab31 | | | 32 | hasCompletedOptimisticState?: boolean; |
| b69ab31 | | | 33 | /** if true, the operation process has exited AND there's no more optimistic changes to uncommitted changes to show */ |
| b69ab31 | | | 34 | hasCompletedUncommittedChangesOptimisticState?: boolean; |
| b69ab31 | | | 35 | /** if true, the operation process has exited AND there's no more optimistic changes to merge conflicts to show */ |
| b69ab31 | | | 36 | hasCompletedMergeConflictsOptimisticState?: boolean; |
| b69ab31 | | | 37 | warnings?: Array<string>; |
| b69ab31 | | | 38 | } & EnsureAssignedTogether<{ |
| b69ab31 | | | 39 | endTime: Date; |
| b69ab31 | | | 40 | exitCode: number; |
| b69ab31 | | | 41 | }>; |
| b69ab31 | | | 42 | |
| b69ab31 | | | 43 | /** |
| b69ab31 | | | 44 | * The process has exited but exit code is unknown. Usually exit code is one byte. |
| b69ab31 | | | 45 | * '-1024' is unlikely to conflict with a valid exit code. |
| b69ab31 | | | 46 | */ |
| b69ab31 | | | 47 | export const EXIT_CODE_FORGET = -1024; |
| b69ab31 | | | 48 | |
| b69ab31 | | | 49 | /** |
| b69ab31 | | | 50 | * Bundle history of previous operations together with the current operation, |
| b69ab31 | | | 51 | * so we can easily manipulate operations together in one piece of state. |
| b69ab31 | | | 52 | */ |
| b69ab31 | | | 53 | export interface OperationList { |
| b69ab31 | | | 54 | /** The currently running operation, or the most recently run if not currently running. */ |
| b69ab31 | | | 55 | currentOperation: OperationInfo | undefined; |
| b69ab31 | | | 56 | /** All previous operations oldest to newest, not including currentOperation */ |
| b69ab31 | | | 57 | operationHistory: Array<OperationInfo>; |
| b69ab31 | | | 58 | } |
| b69ab31 | | | 59 | const defaultOperationList = () => ({currentOperation: undefined, operationHistory: []}); |
| b69ab31 | | | 60 | |
| b69ab31 | | | 61 | function startNewOperation(newOperation: Operation, list: OperationList): OperationList { |
| b69ab31 | | | 62 | if (list.currentOperation?.operation.id === newOperation.id) { |
| b69ab31 | | | 63 | // we already have a new optimistic running operation, don't duplicate it |
| b69ab31 | | | 64 | return {...list}; |
| b69ab31 | | | 65 | } else { |
| b69ab31 | | | 66 | // we need to start a new operation |
| b69ab31 | | | 67 | const operationHistory = [...list.operationHistory]; |
| b69ab31 | | | 68 | if (list.currentOperation != null) { |
| b69ab31 | | | 69 | operationHistory.push(list.currentOperation); |
| b69ab31 | | | 70 | } |
| b69ab31 | | | 71 | const inlineProgress: Array<[string, string]> | undefined = newOperation |
| b69ab31 | | | 72 | .getInitialInlineProgress?.() |
| b69ab31 | | | 73 | ?.map(([k, v]) => [short(k), v]); // inline progress is keyed by short hashes, but let's do that conversion on behalf of operations. |
| b69ab31 | | | 74 | const currentOperation: OperationInfo = { |
| b69ab31 | | | 75 | operation: newOperation, |
| b69ab31 | | | 76 | startTime: new Date(), |
| b69ab31 | | | 77 | inlineProgress: inlineProgress == null ? undefined : new Map(inlineProgress), |
| b69ab31 | | | 78 | }; |
| b69ab31 | | | 79 | return {...list, operationHistory, currentOperation}; |
| b69ab31 | | | 80 | } |
| b69ab31 | | | 81 | } |
| b69ab31 | | | 82 | |
| b69ab31 | | | 83 | /** |
| b69ab31 | | | 84 | * Ask the server if the current operation is still running. |
| b69ab31 | | | 85 | * The server might send back a "forgot" progress and we can mark |
| b69ab31 | | | 86 | * the operation as exited. This is useful when the operation exited |
| b69ab31 | | | 87 | * during disconnection. |
| b69ab31 | | | 88 | */ |
| b69ab31 | | | 89 | export function maybeRemoveForgottenOperation() { |
| b69ab31 | | | 90 | const list = readAtom(operationList); |
| b69ab31 | | | 91 | const operationId = list.currentOperation?.operation.id; |
| b69ab31 | | | 92 | if (operationId != null) { |
| b69ab31 | | | 93 | serverAPI.postMessage({ |
| b69ab31 | | | 94 | type: 'requestMissedOperationProgress', |
| b69ab31 | | | 95 | operationId, |
| b69ab31 | | | 96 | }); |
| b69ab31 | | | 97 | } |
| b69ab31 | | | 98 | } |
| b69ab31 | | | 99 | |
| b69ab31 | | | 100 | export const operationList = atomResetOnCwdChange<OperationList>(defaultOperationList()); |
| b69ab31 | | | 101 | registerCleanup( |
| b69ab31 | | | 102 | operationList, |
| b69ab31 | | | 103 | serverAPI.onSetup(() => maybeRemoveForgottenOperation()), |
| b69ab31 | | | 104 | import.meta.hot, |
| b69ab31 | | | 105 | ); |
| b69ab31 | | | 106 | registerDisposable( |
| b69ab31 | | | 107 | operationList, |
| b69ab31 | | | 108 | serverAPI.onMessageOfType('operationProgress', progress => { |
| b69ab31 | | | 109 | switch (progress.kind) { |
| b69ab31 | | | 110 | case 'spawn': |
| b69ab31 | | | 111 | writeAtom(operationList, list => { |
| b69ab31 | | | 112 | const operation = operationsById.get(progress.id); |
| b69ab31 | | | 113 | if (operation == null) { |
| b69ab31 | | | 114 | return list; |
| b69ab31 | | | 115 | } |
| b69ab31 | | | 116 | |
| b69ab31 | | | 117 | return startNewOperation(operation, list); |
| b69ab31 | | | 118 | }); |
| b69ab31 | | | 119 | break; |
| b69ab31 | | | 120 | case 'stdout': |
| b69ab31 | | | 121 | case 'stderr': |
| b69ab31 | | | 122 | writeAtom(operationList, current => { |
| b69ab31 | | | 123 | const currentOperation = current.currentOperation; |
| b69ab31 | | | 124 | if (currentOperation == null) { |
| b69ab31 | | | 125 | return current; |
| b69ab31 | | | 126 | } |
| b69ab31 | | | 127 | |
| b69ab31 | | | 128 | return { |
| b69ab31 | | | 129 | ...current, |
| b69ab31 | | | 130 | currentOperation: { |
| b69ab31 | | | 131 | ...currentOperation, |
| b69ab31 | | | 132 | commandOutput: [...(currentOperation?.commandOutput ?? []), progress.message], |
| b69ab31 | | | 133 | currentProgress: undefined, // hide progress on new stdout, so it doesn't appear stuck |
| b69ab31 | | | 134 | }, |
| b69ab31 | | | 135 | }; |
| b69ab31 | | | 136 | }); |
| b69ab31 | | | 137 | break; |
| b69ab31 | | | 138 | case 'inlineProgress': |
| b69ab31 | | | 139 | writeAtom(operationList, current => { |
| b69ab31 | | | 140 | const currentOperation = current.currentOperation; |
| b69ab31 | | | 141 | if (currentOperation == null) { |
| b69ab31 | | | 142 | return current; |
| b69ab31 | | | 143 | } |
| b69ab31 | | | 144 | |
| b69ab31 | | | 145 | let inlineProgress: undefined | Map<string, string> = |
| b69ab31 | | | 146 | current.currentOperation?.inlineProgress ?? new Map(); |
| b69ab31 | | | 147 | if (progress.hash) { |
| b69ab31 | | | 148 | if (progress.message) { |
| b69ab31 | | | 149 | inlineProgress.set(progress.hash, progress.message); |
| b69ab31 | | | 150 | } else { |
| b69ab31 | | | 151 | inlineProgress.delete(progress.hash); |
| b69ab31 | | | 152 | } |
| b69ab31 | | | 153 | } else { |
| b69ab31 | | | 154 | inlineProgress = undefined; |
| b69ab31 | | | 155 | } |
| b69ab31 | | | 156 | |
| b69ab31 | | | 157 | const newCommandOutput = [...(currentOperation?.commandOutput ?? [])]; |
| b69ab31 | | | 158 | if (progress.hash && progress.message) { |
| b69ab31 | | | 159 | // also add inline progress message as if it was on stdout, |
| b69ab31 | | | 160 | // so you can see it when reading back the final output |
| b69ab31 | | | 161 | newCommandOutput.push(`${progress.hash} - ${progress.message}\n`); |
| b69ab31 | | | 162 | } |
| b69ab31 | | | 163 | |
| b69ab31 | | | 164 | return { |
| b69ab31 | | | 165 | ...current, |
| b69ab31 | | | 166 | currentOperation: { |
| b69ab31 | | | 167 | ...currentOperation, |
| b69ab31 | | | 168 | inlineProgress, |
| b69ab31 | | | 169 | }, |
| b69ab31 | | | 170 | }; |
| b69ab31 | | | 171 | }); |
| b69ab31 | | | 172 | break; |
| b69ab31 | | | 173 | case 'progress': |
| b69ab31 | | | 174 | writeAtom(operationList, current => { |
| b69ab31 | | | 175 | const currentOperation = current.currentOperation; |
| b69ab31 | | | 176 | if (currentOperation == null) { |
| b69ab31 | | | 177 | return current; |
| b69ab31 | | | 178 | } |
| b69ab31 | | | 179 | |
| b69ab31 | | | 180 | const newCommandOutput = [...(currentOperation?.commandOutput ?? [])]; |
| b69ab31 | | | 181 | if (newCommandOutput.at(-1)?.trim() !== progress.progress.message) { |
| b69ab31 | | | 182 | // also add progress message as if it was on stdout, |
| b69ab31 | | | 183 | // so you can see it when reading back the final output, |
| b69ab31 | | | 184 | // but only if it's a different progress message than we've seen. |
| b69ab31 | | | 185 | newCommandOutput.push(progress.progress.message + '\n'); |
| b69ab31 | | | 186 | } |
| b69ab31 | | | 187 | |
| b69ab31 | | | 188 | return { |
| b69ab31 | | | 189 | ...current, |
| b69ab31 | | | 190 | currentOperation: { |
| b69ab31 | | | 191 | ...currentOperation, |
| b69ab31 | | | 192 | commandOutput: newCommandOutput, |
| b69ab31 | | | 193 | currentProgress: progress.progress, |
| b69ab31 | | | 194 | }, |
| b69ab31 | | | 195 | }; |
| b69ab31 | | | 196 | }); |
| b69ab31 | | | 197 | break; |
| b69ab31 | | | 198 | case 'warning': |
| b69ab31 | | | 199 | writeAtom(operationList, current => { |
| b69ab31 | | | 200 | const currentOperation = current.currentOperation; |
| b69ab31 | | | 201 | if (currentOperation == null) { |
| b69ab31 | | | 202 | return current; |
| b69ab31 | | | 203 | } |
| b69ab31 | | | 204 | const warnings = [...(currentOperation?.warnings ?? []), progress.warning]; |
| b69ab31 | | | 205 | return { |
| b69ab31 | | | 206 | ...current, |
| b69ab31 | | | 207 | currentOperation: { |
| b69ab31 | | | 208 | ...currentOperation, |
| b69ab31 | | | 209 | warnings, |
| b69ab31 | | | 210 | }, |
| b69ab31 | | | 211 | }; |
| b69ab31 | | | 212 | }); |
| b69ab31 | | | 213 | break; |
| b69ab31 | | | 214 | case 'exit': |
| b69ab31 | | | 215 | case 'forgot': |
| b69ab31 | | | 216 | writeAtom(operationList, current => { |
| b69ab31 | | | 217 | const currentOperation = current.currentOperation; |
| b69ab31 | | | 218 | |
| b69ab31 | | | 219 | let operationThatExited: OperationInfo | undefined; |
| b69ab31 | | | 220 | |
| b69ab31 | | | 221 | if ( |
| b69ab31 | | | 222 | currentOperation == null || |
| b69ab31 | | | 223 | currentOperation.exitCode != null || |
| b69ab31 | | | 224 | currentOperation.operation.id !== progress.id |
| b69ab31 | | | 225 | ) { |
| b69ab31 | | | 226 | // We've seen cases where we somehow got this exit out of order. |
| b69ab31 | | | 227 | // Instead of updating the currentOperation, we need to find the matching historical operation. |
| b69ab31 | | | 228 | // (which has the matching ID, and as long as it hasn't already been marked as exited) |
| b69ab31 | | | 229 | |
| b69ab31 | | | 230 | operationThatExited = current.operationHistory.find( |
| b69ab31 | | | 231 | op => op.operation.id === progress.id && op.exitCode == null, |
| b69ab31 | | | 232 | ); |
| b69ab31 | | | 233 | |
| b69ab31 | | | 234 | window.globalIslClientTracker.track('ExitMessageOutOfOrder', { |
| b69ab31 | | | 235 | extras: { |
| b69ab31 | | | 236 | operationThatExited: operationThatExited?.operation.trackEventName, |
| b69ab31 | | | 237 | }, |
| b69ab31 | | | 238 | }); |
| b69ab31 | | | 239 | } |
| b69ab31 | | | 240 | |
| b69ab31 | | | 241 | if (operationThatExited == null) { |
| b69ab31 | | | 242 | operationThatExited = currentOperation; |
| b69ab31 | | | 243 | } |
| b69ab31 | | | 244 | |
| b69ab31 | | | 245 | if (operationThatExited == null) { |
| b69ab31 | | | 246 | // We can't do anything about this. |
| b69ab31 | | | 247 | return current; |
| b69ab31 | | | 248 | } |
| b69ab31 | | | 249 | |
| b69ab31 | | | 250 | const {exitCode, timestamp} = |
| b69ab31 | | | 251 | progress.kind === 'exit' |
| b69ab31 | | | 252 | ? progress |
| b69ab31 | | | 253 | : {exitCode: EXIT_CODE_FORGET, timestamp: Date.now()}; |
| b69ab31 | | | 254 | const complete = operationCompletionCallbacks.get(operationThatExited.operation.id); |
| b69ab31 | | | 255 | complete?.( |
| b69ab31 | | | 256 | exitCode === 0 ? undefined : new Error(`Process exited with code ${exitCode}`), |
| b69ab31 | | | 257 | ); |
| b69ab31 | | | 258 | operationCompletionCallbacks.delete(operationThatExited.operation.id); |
| b69ab31 | | | 259 | |
| b69ab31 | | | 260 | const updatedOperation = { |
| b69ab31 | | | 261 | ...operationThatExited, |
| b69ab31 | | | 262 | exitCode, |
| b69ab31 | | | 263 | endTime: new Date(timestamp), |
| b69ab31 | | | 264 | inlineProgress: undefined, // inline progress never lasts after exiting |
| b69ab31 | | | 265 | }; |
| b69ab31 | | | 266 | |
| b69ab31 | | | 267 | if (operationThatExited === currentOperation) { |
| b69ab31 | | | 268 | return { |
| b69ab31 | | | 269 | ...current, |
| b69ab31 | | | 270 | currentOperation: updatedOperation, |
| b69ab31 | | | 271 | }; |
| b69ab31 | | | 272 | } else { |
| b69ab31 | | | 273 | return { |
| b69ab31 | | | 274 | ...current, |
| b69ab31 | | | 275 | operationHistory: current.operationHistory.map(op => { |
| b69ab31 | | | 276 | if (op === operationThatExited) { |
| b69ab31 | | | 277 | return updatedOperation; |
| b69ab31 | | | 278 | } |
| b69ab31 | | | 279 | return op; |
| b69ab31 | | | 280 | }), |
| b69ab31 | | | 281 | }; |
| b69ab31 | | | 282 | } |
| b69ab31 | | | 283 | }); |
| b69ab31 | | | 284 | break; |
| b69ab31 | | | 285 | } |
| b69ab31 | | | 286 | }), |
| b69ab31 | | | 287 | import.meta.hot, |
| b69ab31 | | | 288 | ); |
| b69ab31 | | | 289 | |
| b69ab31 | | | 290 | /** If an operation in the queue fails, it will remove all further queued operations. |
| b69ab31 | | | 291 | * On such an error, we move the remaining operations into this separate state to be shown in the UI as a warning. |
| b69ab31 | | | 292 | * This lets you see and understand what actions you took that were "reverted", so you might recreate those steps. */ |
| b69ab31 | | | 293 | export const queuedOperationsErrorAtom = atomResetOnCwdChange< |
| b69ab31 | | | 294 | | {error: Error; operationThatErrored: Operation | undefined; operations: Array<Operation>} |
| b69ab31 | | | 295 | | undefined |
| b69ab31 | | | 296 | >(undefined); |
| b69ab31 | | | 297 | |
| b69ab31 | | | 298 | export const inlineProgressByHash = atomFamilyWeak((hash: Hash) => |
| b69ab31 | | | 299 | atom(get => { |
| b69ab31 | | | 300 | const info = get(operationList); |
| b69ab31 | | | 301 | const inlineProgress = info.currentOperation?.inlineProgress; |
| b69ab31 | | | 302 | if (inlineProgress == null) { |
| b69ab31 | | | 303 | return undefined; |
| b69ab31 | | | 304 | } |
| b69ab31 | | | 305 | const shortHash = short(hash); // progress messages come indexed by short hash |
| b69ab31 | | | 306 | return inlineProgress.get(shortHash); |
| b69ab31 | | | 307 | }), |
| b69ab31 | | | 308 | ); |
| b69ab31 | | | 309 | |
| b69ab31 | | | 310 | export const operationBeingPreviewed = atomResetOnCwdChange<Operation | undefined>(undefined); |
| b69ab31 | | | 311 | |
| b69ab31 | | | 312 | /** We don't send entire operations to the server, since not all fields are serializable. |
| b69ab31 | | | 313 | * Thus, when the server tells us about the queue of operations, we need to know which operation it's talking about. |
| b69ab31 | | | 314 | * Store recently run operations by id. Add to this map whenever a new operation is run. Remove when an operation process exits (successfully or unsuccessfully) |
| b69ab31 | | | 315 | */ |
| b69ab31 | | | 316 | const operationsById = new Map<string, Operation>(); |
| b69ab31 | | | 317 | /** Store callbacks to run when an operation completes. This is stored outside of the operation since Operations are typically Immutable. */ |
| b69ab31 | | | 318 | const operationCompletionCallbacks = new Map<string, (error?: Error) => void>(); |
| b69ab31 | | | 319 | |
| b69ab31 | | | 320 | /** |
| b69ab31 | | | 321 | * Subscribe to an operation exiting. Useful for handling cases where an operation fails |
| b69ab31 | | | 322 | * and it should reset the UI to try again. |
| b69ab31 | | | 323 | */ |
| b69ab31 | | | 324 | export function onOperationExited( |
| b69ab31 | | | 325 | cb: ( |
| b69ab31 | | | 326 | message: ServerToClientMessage & {type: 'operationProgress'; kind: 'exit'}, |
| b69ab31 | | | 327 | operation: Operation, |
| b69ab31 | | | 328 | ) => unknown, |
| b69ab31 | | | 329 | ): Disposable { |
| b69ab31 | | | 330 | return serverAPI.onMessageOfType('operationProgress', progress => { |
| b69ab31 | | | 331 | if (progress.kind === 'exit') { |
| b69ab31 | | | 332 | const op = operationsById.get(progress.id); |
| b69ab31 | | | 333 | if (op) { |
| b69ab31 | | | 334 | cb(progress, op); |
| b69ab31 | | | 335 | } |
| b69ab31 | | | 336 | } |
| b69ab31 | | | 337 | }); |
| b69ab31 | | | 338 | } |
| b69ab31 | | | 339 | |
| b69ab31 | | | 340 | /** |
| b69ab31 | | | 341 | * If no operations are running or queued, returns undefined. |
| b69ab31 | | | 342 | * If something is running or queued, return a Promise that resolves |
| b69ab31 | | | 343 | * when there's no operation running and nothing remains queued (the UI is "idle") |
| b69ab31 | | | 344 | * Does not wait for optimistic state to be resolved, only for commands to finish. |
| b69ab31 | | | 345 | */ |
| b69ab31 | | | 346 | export function waitForNothingRunning(): Promise<void> | undefined { |
| b69ab31 | | | 347 | const currentOperation = readAtom(operationList).currentOperation; |
| b69ab31 | | | 348 | const somethingRunning = currentOperation != null && currentOperation?.exitCode == null; |
| b69ab31 | | | 349 | const anythingQueued = readAtom(queuedOperations).length > 0; |
| b69ab31 | | | 350 | if (!somethingRunning && !anythingQueued) { |
| b69ab31 | | | 351 | // nothing running, nothing queued -> return undefined immediately |
| b69ab31 | | | 352 | return undefined; |
| b69ab31 | | | 353 | } |
| b69ab31 | | | 354 | return serverAPI |
| b69ab31 | | | 355 | .nextMessageMatching( |
| b69ab31 | | | 356 | 'operationProgress', |
| b69ab31 | | | 357 | // something running but nothing queued -> resolve when the operation exits |
| b69ab31 | | | 358 | // something queued -> resolve when the next operation exits, but only once the queue is empty |
| b69ab31 | | | 359 | // something running but exits non-zero -> everything queue'd will be cancelled anyway, resolve immediately |
| b69ab31 | | | 360 | msg => msg.kind === 'exit' && (msg.exitCode !== 0 || readAtom(queuedOperations).length === 0), |
| b69ab31 | | | 361 | ) |
| b69ab31 | | | 362 | .then(() => undefined); |
| b69ab31 | | | 363 | } |
| b69ab31 | | | 364 | |
| b69ab31 | | | 365 | export const queuedOperations = atomResetOnCwdChange<Array<Operation>>([]); |
| b69ab31 | | | 366 | registerDisposable( |
| b69ab31 | | | 367 | queuedOperations, |
| b69ab31 | | | 368 | serverAPI.onMessageOfType('operationProgress', progress => { |
| b69ab31 | | | 369 | switch (progress.kind) { |
| b69ab31 | | | 370 | case 'queue': |
| b69ab31 | | | 371 | case 'spawn': // spawning doubles as our notification to dequeue the next operation, and includes the new queue state. |
| b69ab31 | | | 372 | // Update with the latest queue state. We expect this to be sent whenever we try to run a command but it gets queued. |
| b69ab31 | | | 373 | writeAtom(queuedOperations, () => { |
| b69ab31 | | | 374 | return progress.queue |
| b69ab31 | | | 375 | .map(opId => operationsById.get(opId)) |
| b69ab31 | | | 376 | .filter((op): op is Operation => op != null); |
| b69ab31 | | | 377 | }); |
| b69ab31 | | | 378 | // On spawn, we can clear the queued commands error. The error would have already been shown and then further acted on. |
| b69ab31 | | | 379 | // This wouldn't happen automatically, so we consider this an explicit user acknowledgement. |
| b69ab31 | | | 380 | // This also means this error state and the queuedOperations state should be mutually exclusive. |
| b69ab31 | | | 381 | writeAtom(queuedOperationsErrorAtom, undefined); |
| b69ab31 | | | 382 | break; |
| b69ab31 | | | 383 | case 'error': { |
| b69ab31 | | | 384 | saveQueuedOperationsOnError(progress.id, new Error(progress.error)); |
| b69ab31 | | | 385 | |
| b69ab31 | | | 386 | writeAtom(queuedOperations, []); // empty queue when a command hits an error |
| b69ab31 | | | 387 | break; |
| b69ab31 | | | 388 | } |
| b69ab31 | | | 389 | case 'exit': { |
| b69ab31 | | | 390 | setTimeout(() => { |
| b69ab31 | | | 391 | // we don't need to care about this operation anymore after this tick, |
| b69ab31 | | | 392 | // once all other callsites processing 'operationProgress' messages have run. |
| b69ab31 | | | 393 | operationsById.delete(progress.id); |
| b69ab31 | | | 394 | }); |
| b69ab31 | | | 395 | if (progress.exitCode != null && progress.exitCode !== 0) { |
| b69ab31 | | | 396 | saveQueuedOperationsOnError(progress.id, new Error('command exited with non-zero code')); |
| b69ab31 | | | 397 | |
| b69ab31 | | | 398 | // if any process in the queue exits with an error, the entire queue is cleared. |
| b69ab31 | | | 399 | writeAtom(queuedOperations, []); |
| b69ab31 | | | 400 | } |
| b69ab31 | | | 401 | break; |
| b69ab31 | | | 402 | } |
| b69ab31 | | | 403 | } |
| b69ab31 | | | 404 | }), |
| b69ab31 | | | 405 | import.meta.hot, |
| b69ab31 | | | 406 | ); |
| b69ab31 | | | 407 | |
| b69ab31 | | | 408 | function saveQueuedOperationsOnError(operationIdThatErrored: string, error: Error) { |
| b69ab31 | | | 409 | const queued = readAtom(queuedOperations); |
| b69ab31 | | | 410 | // This may be called twice for the same operation (error, then also exit). |
| b69ab31 | | | 411 | // Don't clear the error state if it's for the same operation, even if the queue is now empty. |
| b69ab31 | | | 412 | if (readAtom(queuedOperationsErrorAtom)?.operationThatErrored?.id !== operationIdThatErrored) { |
| b69ab31 | | | 413 | writeAtom( |
| b69ab31 | | | 414 | queuedOperationsErrorAtom, |
| b69ab31 | | | 415 | queued.length === 0 |
| b69ab31 | | | 416 | ? undefined // invariant: queuedOperationsError.operations should never be [], rather the whole thing is undefined |
| b69ab31 | | | 417 | : { |
| b69ab31 | | | 418 | operationThatErrored: operationsById.get(operationIdThatErrored), |
| b69ab31 | | | 419 | error, |
| b69ab31 | | | 420 | operations: readAtom(queuedOperations), |
| b69ab31 | | | 421 | }, |
| b69ab31 | | | 422 | ); |
| b69ab31 | | | 423 | } |
| b69ab31 | | | 424 | } |
| b69ab31 | | | 425 | |
| b69ab31 | | | 426 | export function getLatestOperationInfo(operation: Operation): OperationInfo | undefined { |
| b69ab31 | | | 427 | const list = readAtom(operationList); |
| b69ab31 | | | 428 | const info = |
| b69ab31 | | | 429 | list.currentOperation?.operation === operation |
| b69ab31 | | | 430 | ? list.currentOperation |
| b69ab31 | | | 431 | : list.operationHistory.find(op => op.operation === operation); |
| b69ab31 | | | 432 | |
| b69ab31 | | | 433 | return info; |
| b69ab31 | | | 434 | } |
| b69ab31 | | | 435 | |
| b69ab31 | | | 436 | function runOperationImpl(operation: Operation): Promise<undefined | Error> { |
| b69ab31 | | | 437 | // TODO: check for hashes in arguments that are known to be obsolete already, |
| b69ab31 | | | 438 | // and mark those to not be rewritten. |
| b69ab31 | | | 439 | serverAPI.postMessage({ |
| b69ab31 | | | 440 | type: 'runOperation', |
| b69ab31 | | | 441 | operation: operation.getRunnableOperation(), |
| b69ab31 | | | 442 | }); |
| b69ab31 | | | 443 | const deferred = defer<undefined | Error>(); |
| b69ab31 | | | 444 | operationCompletionCallbacks.set(operation.id, (err?: Error) => { |
| b69ab31 | | | 445 | deferred.resolve(err); |
| b69ab31 | | | 446 | }); |
| b69ab31 | | | 447 | |
| b69ab31 | | | 448 | operationsById.set(operation.id, operation); |
| b69ab31 | | | 449 | const ongoing = readAtom(operationList); |
| b69ab31 | | | 450 | |
| b69ab31 | | | 451 | if (ongoing?.currentOperation != null && ongoing.currentOperation.exitCode == null) { |
| b69ab31 | | | 452 | // Add to the queue optimistically. The server will tell us the real state of the queue when it gets our run request. |
| b69ab31 | | | 453 | writeAtom(queuedOperations, prev => [...(prev || []), operation]); |
| b69ab31 | | | 454 | } else { |
| b69ab31 | | | 455 | // start a new operation. We need to manage the previous operations |
| b69ab31 | | | 456 | writeAtom(operationList, list => startNewOperation(operation, list)); |
| b69ab31 | | | 457 | } |
| b69ab31 | | | 458 | |
| b69ab31 | | | 459 | // Check periodically with the server that the process is still running. |
| b69ab31 | | | 460 | // This is a fallback in case the server cannot send us "exit" messages. |
| b69ab31 | | | 461 | // This timer will auto disable when currentOperation becomes null. |
| b69ab31 | | | 462 | currentOperationHeartbeatTimer.enabled = true; |
| b69ab31 | | | 463 | |
| b69ab31 | | | 464 | return deferred.promise; |
| b69ab31 | | | 465 | } |
| b69ab31 | | | 466 | |
| b69ab31 | | | 467 | const currentOperationHeartbeatTimer = new Timer(() => { |
| b69ab31 | | | 468 | const currentOp = readAtom(operationList).currentOperation; |
| b69ab31 | | | 469 | if (currentOp == null || currentOp.endTime != null) { |
| b69ab31 | | | 470 | // Stop the timer. |
| b69ab31 | | | 471 | return false; |
| b69ab31 | | | 472 | } |
| b69ab31 | | | 473 | maybeRemoveForgottenOperation(); |
| b69ab31 | | | 474 | }, 5000); |
| b69ab31 | | | 475 | |
| b69ab31 | | | 476 | /** |
| b69ab31 | | | 477 | * Returns callback to run an operation. |
| b69ab31 | | | 478 | * Will be queued by the server if other operations are already running. |
| b69ab31 | | | 479 | * This returns a promise that resolves when this operation has exited |
| b69ab31 | | | 480 | * (though its optimistic state may not have finished resolving yet). |
| b69ab31 | | | 481 | * Note: Most callsites won't await this promise, and just use queueing. If you do, you should probably use `throwOnError = true` to detect errors. |
| b69ab31 | | | 482 | * TODO: should we refactor this into a separate function if you want to await the result, which always throws? |
| b69ab31 | | | 483 | * Note: There's no need to wait for this promise to resolve before starting another operation, |
| b69ab31 | | | 484 | * successive operations will queue up with a nicer UX than if you awaited each one. |
| b69ab31 | | | 485 | */ |
| b69ab31 | | | 486 | export function useRunOperation() { |
| b69ab31 | | | 487 | return useCallback(async (operation: Operation, throwOnError?: boolean): Promise<void> => { |
| 4bb999b | | | 488 | if (readAtom(applicationinfo)?.readOnly) { |
| 4bb999b | | | 489 | return; |
| 4bb999b | | | 490 | } |
| b69ab31 | | | 491 | const result = await runOperationImpl(operation); |
| b69ab31 | | | 492 | if (result != null && throwOnError) { |
| b69ab31 | | | 493 | throw result; |
| b69ab31 | | | 494 | } |
| b69ab31 | | | 495 | }, []); |
| b69ab31 | | | 496 | } |
| b69ab31 | | | 497 | |
| b69ab31 | | | 498 | /** |
| b69ab31 | | | 499 | * Returns callback to abort the running operation. |
| b69ab31 | | | 500 | */ |
| b69ab31 | | | 501 | export function useAbortRunningOperation() { |
| b69ab31 | | | 502 | return useCallback((operationId: string) => { |
| b69ab31 | | | 503 | serverAPI.postMessage({ |
| b69ab31 | | | 504 | type: 'abortRunningOperation', |
| b69ab31 | | | 505 | operationId, |
| b69ab31 | | | 506 | }); |
| b69ab31 | | | 507 | const ongoing = readAtom(operationList); |
| b69ab31 | | | 508 | if (ongoing?.currentOperation?.operation?.id === operationId) { |
| b69ab31 | | | 509 | // Mark 'aborting' as true. |
| b69ab31 | | | 510 | writeAtom(operationList, list => { |
| b69ab31 | | | 511 | const currentOperation = list.currentOperation; |
| b69ab31 | | | 512 | if (currentOperation != null) { |
| b69ab31 | | | 513 | return {...list, currentOperation: {aborting: true, ...currentOperation}}; |
| b69ab31 | | | 514 | } |
| b69ab31 | | | 515 | return list; |
| b69ab31 | | | 516 | }); |
| b69ab31 | | | 517 | } |
| b69ab31 | | | 518 | }, []); |
| b69ab31 | | | 519 | } |
| b69ab31 | | | 520 | |
| b69ab31 | | | 521 | /** |
| b69ab31 | | | 522 | * Returns callback to run the operation currently being previewed, or cancel the preview. |
| b69ab31 | | | 523 | * Set operationBeingPreviewed to start a preview. |
| b69ab31 | | | 524 | */ |
| b69ab31 | | | 525 | export function useRunPreviewedOperation() { |
| b69ab31 | | | 526 | return useCallback((isCancel: boolean, operation?: Operation) => { |
| b69ab31 | | | 527 | if (isCancel) { |
| b69ab31 | | | 528 | writeAtom(operationBeingPreviewed, undefined); |
| b69ab31 | | | 529 | return; |
| b69ab31 | | | 530 | } |
| b69ab31 | | | 531 | |
| b69ab31 | | | 532 | const operationToRun = operation ?? readAtom(operationBeingPreviewed); |
| b69ab31 | | | 533 | writeAtom(operationBeingPreviewed, undefined); |
| b69ab31 | | | 534 | if (operationToRun) { |
| b69ab31 | | | 535 | runOperationImpl(operationToRun); |
| b69ab31 | | | 536 | } |
| b69ab31 | | | 537 | }, []); |
| b69ab31 | | | 538 | } |
| b69ab31 | | | 539 | |
| b69ab31 | | | 540 | /** |
| b69ab31 | | | 541 | * It's possible for optimistic state to be incorrect, e.g. if some assumption about a command is incorrect in an edge case |
| b69ab31 | | | 542 | * but the command doesn't exit non-zero. This provides a backdoor to clear out all ongoing optimistic state from *previous* commands. |
| b69ab31 | | | 543 | * Queued commands and the currently running command will not be affected. |
| b69ab31 | | | 544 | */ |
| b69ab31 | | | 545 | export function useClearAllOptimisticState() { |
| b69ab31 | | | 546 | return useCallback(() => { |
| b69ab31 | | | 547 | writeAtom(operationList, list => { |
| b69ab31 | | | 548 | const operationHistory = [...list.operationHistory]; |
| b69ab31 | | | 549 | for (let i = 0; i < operationHistory.length; i++) { |
| b69ab31 | | | 550 | if (operationHistory[i].exitCode != null) { |
| b69ab31 | | | 551 | if (!operationHistory[i].hasCompletedOptimisticState) { |
| b69ab31 | | | 552 | operationHistory[i] = {...operationHistory[i], hasCompletedOptimisticState: true}; |
| b69ab31 | | | 553 | } |
| b69ab31 | | | 554 | if (!operationHistory[i].hasCompletedUncommittedChangesOptimisticState) { |
| b69ab31 | | | 555 | operationHistory[i] = { |
| b69ab31 | | | 556 | ...operationHistory[i], |
| b69ab31 | | | 557 | hasCompletedUncommittedChangesOptimisticState: true, |
| b69ab31 | | | 558 | }; |
| b69ab31 | | | 559 | } |
| b69ab31 | | | 560 | if (!operationHistory[i].hasCompletedMergeConflictsOptimisticState) { |
| b69ab31 | | | 561 | operationHistory[i] = { |
| b69ab31 | | | 562 | ...operationHistory[i], |
| b69ab31 | | | 563 | hasCompletedMergeConflictsOptimisticState: true, |
| b69ab31 | | | 564 | }; |
| b69ab31 | | | 565 | } |
| b69ab31 | | | 566 | } |
| b69ab31 | | | 567 | } |
| b69ab31 | | | 568 | const currentOperation = |
| b69ab31 | | | 569 | list.currentOperation == null ? undefined : {...list.currentOperation}; |
| b69ab31 | | | 570 | if (currentOperation?.exitCode != null) { |
| b69ab31 | | | 571 | currentOperation.hasCompletedOptimisticState = true; |
| b69ab31 | | | 572 | currentOperation.hasCompletedUncommittedChangesOptimisticState = true; |
| b69ab31 | | | 573 | currentOperation.hasCompletedMergeConflictsOptimisticState = true; |
| b69ab31 | | | 574 | } |
| b69ab31 | | | 575 | return {currentOperation, operationHistory}; |
| b69ab31 | | | 576 | }); |
| b69ab31 | | | 577 | }, []); |
| b69ab31 | | | 578 | } |