11// Automatic updates for boot-service installs, once threads are quiet and the optional hook agrees.
2- // The handoff closes the WorkAdmission gate before the last read and writes a marker; work after
3- // that stays unclaimed and startup recovery starts it after the restart . The gate stays closed
4- // until exit, the launcher's record says no restart, or AUTO_UPDATE_MAX_HOLD_MS passes .
2+ // The handoff closes the WorkAdmission gate and writes a marker; held work starts once the
3+ // restarted server's update is committed . The gate stays closed until exit or the launcher's
4+ // record says no restart is coming ( AUTO_UPDATE_MAX_HOLD_MS applies only while not pending) .
55import {
66 ServerSelfUpdateError ,
77 type ServerSelfUpdateOutcome ,
@@ -50,7 +50,8 @@ export const AUTO_UPDATE_QUIET_POLL_MS = MINUTE;
5050export const AUTO_UPDATE_MAX_QUIET_WAIT_MS = 2 * 60 * MINUTE ;
5151/** After a failed attempt, so a release that cannot install is not fetched every hour. */
5252export const AUTO_UPDATE_FAILURE_BACKOFF_MS = 6 * 60 * MINUTE ;
53- /** New work waits at most this long for the launcher to restart the server, then starts again. */
53+ /** New work waits at most this long for the launcher to restart the server, then starts again,
54+ * unless the launcher's record still shows this update pending. */
5455export const AUTO_UPDATE_MAX_HOLD_MS = 30 * MINUTE ;
5556/** When the launcher's answer was lost (its 30 s reply timeout), how long its record may take to
5657 * show the update pending before the handoff counts as dropped. */
@@ -332,7 +333,8 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
332333 initialAutoUpdateState ( yield * Clock . currentTimeMillis ) ,
333334 ) ;
334335 const loggedUnavailable = yield * Ref . make ( false ) ;
335- const markerSettled = yield * Ref . make ( false ) ;
336+ const loggedUnsettled = yield * Ref . make ( false ) ;
337+ const loggedPastCap = yield * Ref . make ( false ) ;
336338 const lastUnreadable = yield * Ref . make < string | null > ( null ) ;
337339 const backedOffFailureId = yield * Ref . make < string | undefined > ( undefined ) ;
338340 const hookCommand = deps . preferences . pipe (
@@ -363,8 +365,9 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
363365 const command = preferences . autoUpdateQuietCommand ;
364366 const hook = yield * deps . askHook ( command , targetVersion ) ;
365367 if ( hook !== null ) return hook ;
366- // Taken before the gate closes: anything requested from here on is either counted below or
367- // held, and the marker lets startup recovery start the held part.
368+ // Anything saved after the gate closes is either counted below (and stops the update) or held,
369+ // and the marker lets startup recovery keep and later start the held part. Recovery decides by
370+ // that order, not by request times; `heldAt` only tells a fresh marker from a stale one.
368371 const heldAt = DateTime . formatIso ( yield * DateTime . now ) ;
369372 const starting = yield * deps . gate . hold ;
370373 let reason =
@@ -542,36 +545,45 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
542545 } ) ;
543546
544547 /** The gate stays closed until exit. It opens again only when the launcher's record shows it
545- * will not restart this server, or after AUTO_UPDATE_MAX_HOLD_MS; an unreadable record or a
546- * lost answer is not a reason to let work start while a restart may still come. */
548+ * will not restart this server, or after AUTO_UPDATE_MAX_HOLD_MS unless the record shows this
549+ * update pending (the restart is still coming: work that started now would be cut off by it).
550+ * Before the cap an unreadable record or a lost answer keeps the hold too. */
547551 const watchHandoff = Effect . fn ( "cloud.auto_update.watchHandoff" ) ( function * (
548552 current : Extract < AutoUpdateState , { phase : "handed-off" } > ,
549553 nowMs : number ,
550554 ) {
551555 const heldMs = nowMs - current . since ;
552- if ( heldMs >= AUTO_UPDATE_MAX_HOLD_MS ) {
556+ const pastCap = heldMs >= AUTO_UPDATE_MAX_HOLD_MS ;
557+ const record = yield * deps . launcherRecord ;
558+ if (
559+ record . type === "update" &&
560+ record . update . status === "pending" &&
561+ record . update . targetVersion === current . version
562+ ) {
563+ yield * Ref . set ( lastUnreadable , null ) ;
564+ if ( ! current . confirmed ) yield * Ref . set ( state , { ...current , confirmed : true } ) ;
565+ if ( pastCap && ! ( yield * Ref . getAndSet ( loggedPastCap , true ) ) ) {
566+ yield * Effect . logWarning (
567+ "Automatic server update: the restart is taking long; new work keeps waiting while the launcher has it pending." ,
568+ { targetVersion : current . version , heldMinutes : Math . round ( heldMs / MINUTE ) } ,
569+ ) ;
570+ }
571+ return ;
572+ }
573+ if ( pastCap ) {
553574 return yield * dropHandoff (
554575 current ,
555576 nowMs ,
556577 `no restart after ${ Math . round ( heldMs / MINUTE ) } minutes` ,
557578 ) ;
558579 }
559- const record = yield * deps . launcherRecord ;
560580 if ( record . type === "unreadable" ) {
561581 return yield * logUnreadable (
562582 record . reason ,
563583 "Automatic server update cannot read the launcher state; new work keeps waiting." ,
564584 ) ;
565585 }
566586 yield * Ref . set ( lastUnreadable , null ) ;
567- if (
568- record . type === "update" &&
569- record . update . status === "pending" &&
570- record . update . targetVersion === current . version
571- ) {
572- if ( ! current . confirmed ) yield * Ref . set ( state , { ...current , confirmed : true } ) ;
573- return ;
574- }
575587 if ( ! current . confirmed && heldMs < AUTO_UPDATE_UNCONFIRMED_GRACE_MS ) return ;
576588 yield * dropHandoff (
577589 current ,
@@ -582,21 +594,6 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
582594 ) ;
583595 } ) ;
584596
585- /** A marker left by the process before this one served startup recovery. Kept while the
586- * launcher's trial is pending: a rollback restores the database as it was at the stop, and the
587- * previous version's recovery needs the marker again. */
588- const settleMarker = Effect . gen ( function * ( ) {
589- if ( yield * Ref . get ( markerSettled ) ) return ;
590- // Startup recovery runs after this loop starts and must see the marker first.
591- if ( ! ( yield * deps . gate . updateMarkerRead ) ) return ;
592- if ( yield * deps . gate . isHeld ) return ;
593- const record = yield * deps . launcherRecord ;
594- if ( record . type === "unreadable" ) return ;
595- if ( record . type === "update" && record . update . status === "pending" ) return ;
596- yield * deps . gate . clearUpdateMarker ;
597- yield * Ref . set ( markerSettled , true ) ;
598- } ) ;
599-
600597 const probe = Effect . fn ( "cloud.auto_update.probe" ) ( function * (
601598 current : Extract < AutoUpdateState , { phase : "waiting" } > ,
602599 nowMs : number ,
@@ -669,7 +666,6 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
669666 const current = yield * Ref . get ( state ) ;
670667 // A handoff in progress is followed whatever the settings say now.
671668 if ( current . phase === "handed-off" ) return yield * watchHandoff ( current , nowMs ) ;
672- yield * settleMarker ;
673669 const preferences = yield * deps . preferences ;
674670 if ( ! preferences . autoUpdateServer ) {
675671 if ( current . phase === "waiting" ) {
@@ -700,6 +696,16 @@ export const makeAutoUpdater = Effect.fn("cloud.auto_update.make")(function* (
700696 }
701697 return ;
702698 }
699+ // Work an earlier update held must start before this server holds work again; startup starts
700+ // it once its own update is committed.
701+ if ( ! ( yield * deps . gate . heldWorkSettled ) ) {
702+ if ( ! ( yield * Ref . getAndSet ( loggedUnsettled , true ) ) ) {
703+ yield * Effect . logInfo (
704+ "Automatic server update waits until work held by the last update has started." ,
705+ ) ;
706+ }
707+ return ;
708+ }
703709 const step = nextAutoUpdateStep ( current , nowMs ) ;
704710 if ( step . type === "check" ) return yield * check ( nowMs ) ;
705711 if ( step . type === "probe" && current . phase === "waiting" ) return yield * probe ( current , nowMs ) ;
0 commit comments