mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
fix: daemon lifecycle cleanup and error logging improvements
- Refactor HealthDaemon to use a tracked session (AbortController + awaitable promise) instead of fire-and-forget health check loops, preventing health checks from running after a service is stopped - Stop health checks before terminating daemon to avoid false crash reports during intentional shutdown - Guard onExit callbacks with AbortSignal to prevent stale session callbacks - Add logErrorOnce utility to deduplicate repeated error logging - Fix SystemForEmbassy.stop() to capture clean promise before deleting ref - Treat SIGTERM (signal 15) as successful exit in subcontainer sync - Fix asError to return original Error instead of wrapping in new Error - Remove unused ExtendedVersion import from Backups.ts
This commit is contained in:
2
container-runtime/package-lock.json
generated
2
container-runtime/package-lock.json
generated
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"../sdk/dist": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.61",
|
||||
"version": "0.4.0-beta.62",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -445,15 +445,14 @@ export class SystemForEmbassy implements System {
|
||||
}
|
||||
callCallback(_callback: number, _args: any[]): void {}
|
||||
async stop(): Promise<void> {
|
||||
const { currentRunning } = this
|
||||
this.currentRunning?.clean()
|
||||
delete this.currentRunning
|
||||
if (currentRunning) {
|
||||
await currentRunning.clean({
|
||||
const clean = this.currentRunning?.clean({
|
||||
timeout: fromDuration(
|
||||
(this.manifest.main["sigterm-timeout"] as any) || "30s",
|
||||
),
|
||||
})
|
||||
delete this.currentRunning
|
||||
if (clean) {
|
||||
await clean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -768,7 +768,7 @@ pub fn exec(
|
||||
stderr_thread.map(|t| t.join().unwrap());
|
||||
if let Some(code) = exit.code() {
|
||||
std::process::exit(code);
|
||||
} else if exit.success() {
|
||||
} else if exit.success() || exit.signal() == Some(15) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
export const asError = (e: unknown) => {
|
||||
if (e instanceof Error) {
|
||||
return new Error(e as any)
|
||||
return e
|
||||
}
|
||||
if (typeof e === 'string') {
|
||||
return new Error(`${e}`)
|
||||
|
||||
@@ -32,3 +32,4 @@ export { deepEqual } from './deepEqual'
|
||||
export { AbortedError } from './AbortedError'
|
||||
export * as regexes from './regexes'
|
||||
export { stringFromStdErrOut } from './stringFromStdErrOut'
|
||||
export { logErrorOnce } from './logErrorOnce'
|
||||
|
||||
9
sdk/base/lib/util/logErrorOnce.ts
Normal file
9
sdk/base/lib/util/logErrorOnce.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const loggedErrors = new WeakSet<object>()
|
||||
|
||||
export function logErrorOnce(err: unknown) {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
if (loggedErrors.has(err)) return
|
||||
loggedErrors.add(err)
|
||||
}
|
||||
console.error(err)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import * as T from '../../../base/lib/types'
|
||||
import * as child_process from 'child_process'
|
||||
import * as fs from 'fs/promises'
|
||||
import { Affine, asError } from '../util'
|
||||
import { ExtendedVersion, VersionRange } from '../../../base/lib'
|
||||
import { InitKind, InitScript } from '../../../base/lib/inits'
|
||||
|
||||
/** Default rsync options used for backup and restore operations */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Drop, splitCommand } from '../util'
|
||||
import * as cp from 'child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { DaemonCommandType, ExecCommandOptions, ExecFnOptions } from './Daemons'
|
||||
import { logErrorOnce } from '../../../base/lib/util/logErrorOnce'
|
||||
|
||||
/**
|
||||
* Low-level controller for a single running process inside a subcontainer (or as a JS function).
|
||||
@@ -220,6 +221,6 @@ export class CommandController<
|
||||
}
|
||||
}
|
||||
onDrop(): void {
|
||||
this.term().catch(console.error)
|
||||
this.term().catch(logErrorOnce)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as T from '../../../base/lib/types'
|
||||
import { asError } from '../../../base/lib/util/asError'
|
||||
import { logErrorOnce } from '../../../base/lib/util/logErrorOnce'
|
||||
import { Drop } from '../util'
|
||||
import {
|
||||
SubContainer,
|
||||
@@ -64,7 +65,7 @@ export class Daemon<
|
||||
)
|
||||
const res = new Daemon(subc, startCommand)
|
||||
effects.onLeaveContext(() => {
|
||||
res.term({ destroySubcontainer: true }).catch((e) => console.error(e))
|
||||
res.term({ destroySubcontainer: true }).catch((e) => logErrorOnce(e))
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -86,7 +87,7 @@ export class Daemon<
|
||||
if (this.commandController)
|
||||
await this.commandController
|
||||
.term({})
|
||||
.catch((err) => console.error(err))
|
||||
.catch((err) => logErrorOnce(err))
|
||||
try {
|
||||
this.commandController = await this.startCommand()
|
||||
if (!this.shouldBeRunning) {
|
||||
@@ -97,7 +98,7 @@ export class Daemon<
|
||||
const success = await this.commandController.wait().then(
|
||||
(_) => true,
|
||||
(err) => {
|
||||
console.error(err)
|
||||
if (this.shouldBeRunning) logErrorOnce(err)
|
||||
return false
|
||||
},
|
||||
)
|
||||
@@ -147,7 +148,7 @@ export class Daemon<
|
||||
this.onExitFns = []
|
||||
}
|
||||
if (this.exiting) {
|
||||
await this.exiting.catch(console.error)
|
||||
await this.exiting.catch(logErrorOnce)
|
||||
if (termOptions?.destroySubcontainer) {
|
||||
await this.subcontainer?.destroy()
|
||||
}
|
||||
@@ -172,6 +173,6 @@ export class Daemon<
|
||||
this.onExitFns.push(fn)
|
||||
}
|
||||
onDrop(): void {
|
||||
this.term().catch((e) => console.error(asError(e)))
|
||||
this.term().catch((e) => logErrorOnce(asError(e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Ready } from './Daemons'
|
||||
import { Daemon } from './Daemon'
|
||||
import { SetHealth, Effects, SDKManifest } from '../../../base/lib/types'
|
||||
|
||||
const oncePromise = <T>() => {
|
||||
let resolve: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { resolve: resolve!, promise }
|
||||
}
|
||||
|
||||
export const EXIT_SUCCESS = 'EXIT_SUCCESS' as const
|
||||
|
||||
/**
|
||||
@@ -29,6 +21,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
|
||||
private resolveReady: (() => void) | undefined
|
||||
private resolvedReady: boolean = false
|
||||
private readyPromise: Promise<void>
|
||||
private session: { abort: AbortController; done: Promise<void> } | null = null
|
||||
constructor(
|
||||
readonly daemon: Daemon<Manifest> | null,
|
||||
readonly dependencies: HealthDaemon<Manifest>[],
|
||||
@@ -54,7 +47,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
|
||||
}) {
|
||||
this.healthWatchers = []
|
||||
this.running = false
|
||||
this.healthCheckCleanup?.()
|
||||
await this.stopSession()
|
||||
|
||||
await this.daemon?.term({
|
||||
...termOptions,
|
||||
@@ -77,20 +70,25 @@ export class HealthDaemon<Manifest extends SDKManifest> {
|
||||
|
||||
if (newStatus) {
|
||||
console.debug(`Launching ${this.id}...`)
|
||||
this.setupHealthCheck()
|
||||
this.startSession()
|
||||
this.daemon?.start()
|
||||
this.started = performance.now()
|
||||
} else {
|
||||
console.debug(`Stopping ${this.id}...`)
|
||||
this.daemon?.term()
|
||||
await this.turnOffHealthCheck()
|
||||
await this.stopSession()
|
||||
await this.daemon?.term()
|
||||
}
|
||||
}
|
||||
|
||||
private healthCheckCleanup: (() => Promise<null>) | null = null
|
||||
private async turnOffHealthCheck() {
|
||||
await this.healthCheckCleanup?.()
|
||||
private async stopSession() {
|
||||
if (!this.session) return
|
||||
this.session.abort.abort()
|
||||
await this.session.done
|
||||
this.session = null
|
||||
this.resetReady()
|
||||
}
|
||||
|
||||
private resetReady() {
|
||||
this.resolvedReady = false
|
||||
this.readyPromise = new Promise(
|
||||
(resolve) =>
|
||||
@@ -100,8 +98,14 @@ export class HealthDaemon<Manifest extends SDKManifest> {
|
||||
}),
|
||||
)
|
||||
}
|
||||
private async setupHealthCheck() {
|
||||
|
||||
private startSession() {
|
||||
this.session?.abort.abort()
|
||||
|
||||
const abort = new AbortController()
|
||||
|
||||
this.daemon?.onExit((success) => {
|
||||
if (abort.signal.aborted) return
|
||||
if (success && this.ready === 'EXIT_SUCCESS') {
|
||||
this.setHealth({ result: 'success', message: null })
|
||||
} else if (!success) {
|
||||
@@ -116,42 +120,49 @@ export class HealthDaemon<Manifest extends SDKManifest> {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const done =
|
||||
this.ready === 'EXIT_SUCCESS'
|
||||
? Promise.resolve()
|
||||
: this.runHealthCheckLoop(abort.signal)
|
||||
|
||||
this.session = { abort, done }
|
||||
}
|
||||
|
||||
private async runHealthCheckLoop(signal: AbortSignal): Promise<void> {
|
||||
if (this.ready === 'EXIT_SUCCESS') return
|
||||
if (this.healthCheckCleanup) return
|
||||
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
|
||||
lastResult: this._health.result,
|
||||
}))
|
||||
|
||||
const { promise: status, resolve: setStatus } = oncePromise<{
|
||||
done: true
|
||||
}>()
|
||||
const { promise: exited, resolve: setExited } = oncePromise<null>()
|
||||
new Promise(async () => {
|
||||
if (this.ready === 'EXIT_SUCCESS') return
|
||||
const aborted = new Promise<{ done: true }>((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve({ done: true }), {
|
||||
once: true,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
for (
|
||||
let res = await Promise.race([status, trigger.next()]);
|
||||
let res = await Promise.race([aborted, trigger.next()]);
|
||||
!res.done;
|
||||
res = await Promise.race([status, trigger.next()])
|
||||
res = await Promise.race([aborted, trigger.next()])
|
||||
) {
|
||||
const response: HealthCheckResult = await Promise.resolve(
|
||||
this.ready.fn(),
|
||||
).catch((err) => {
|
||||
return {
|
||||
result: 'failure',
|
||||
result: 'failure' as const,
|
||||
message: 'message' in err ? err.message : String(err),
|
||||
}
|
||||
})
|
||||
|
||||
if (signal.aborted) break
|
||||
await this.setHealth(response)
|
||||
}
|
||||
setExited(null)
|
||||
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
|
||||
|
||||
this.healthCheckCleanup = async () => {
|
||||
setStatus({ done: true })
|
||||
await exited
|
||||
this.healthCheckCleanup = null
|
||||
return null
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
console.error(`Daemon ${this.id} health check failed: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user