add support for "oneshot" daemons (#2917)

* add support for "oneshot" daemons

* add docs for oneshot

* add support for runAsInit in daemon.of

* beta.13
This commit is contained in:
Aiden McClelland
2025-05-01 16:00:35 -06:00
committed by GitHub
parent e6f0067728
commit 828e13adbb
7 changed files with 197 additions and 53 deletions

View File

@@ -56,29 +56,27 @@ export class MainLoop {
if (jsMain) {
throw new Error("Unreachable")
}
const daemon = new Daemon(async () => {
const subcontainer = await DockerProcedureContainer.createSubContainer(
effects,
this.system.manifest.id,
this.system.manifest.main,
this.system.manifest.volumes,
`Main - ${currentCommand.join(" ")}`,
)
return CommandController.of()(
this.effects,
subcontainer,
currentCommand,
{
runAsInit: true,
env: {
TINI_SUBREAPER: "true",
},
sigtermTimeout: utils.inMs(
this.system.manifest.main["sigterm-timeout"],
),
const subcontainer = await DockerProcedureContainer.createSubContainer(
effects,
this.system.manifest.id,
this.system.manifest.main,
this.system.manifest.volumes,
`Main - ${currentCommand.join(" ")}`,
)
const daemon = await Daemon.of()(
this.effects,
subcontainer,
currentCommand,
{
runAsInit: true,
env: {
TINI_SUBREAPER: "true",
},
)
})
sigtermTimeout: utils.inMs(
this.system.manifest.main["sigterm-timeout"],
),
},
)
daemon.start()
return {

View File

@@ -1,9 +1,8 @@
import * as T from "../../../base/lib/types"
import { asError } from "../../../base/lib/util/asError"
import { Drop } from "../util"
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
import { ExecSpawnable, SubContainer } from "../util/SubContainer"
import { CommandController } from "./CommandController"
import { Mounts } from "./Mounts"
const TIMEOUT_INCREMENT_MS = 1000
const MAX_TIMEOUT_MS = 30000
@@ -15,8 +14,11 @@ const MAX_TIMEOUT_MS = 30000
export class Daemon<Manifest extends T.SDKManifest> extends Drop {
private commandController: CommandController<Manifest> | null = null
private shouldBeRunning = false
constructor(
protected exitedSuccess = false
protected constructor(
private startCommand: () => Promise<CommandController<Manifest>>,
readonly oneshot: boolean = false,
protected onExitSuccessFns: (() => void)[] = [],
) {
super()
}
@@ -29,6 +31,7 @@ export class Daemon<Manifest extends T.SDKManifest> extends Drop {
subcontainer: SubContainer<Manifest>,
command: T.CommandType,
options: {
runAsInit?: boolean
env?:
| {
[variable: string]: string
@@ -56,6 +59,7 @@ export class Daemon<Manifest extends T.SDKManifest> extends Drop {
return
}
this.shouldBeRunning = true
this.exitedSuccess = false
let timeoutCounter = 0
;(async () => {
while (this.shouldBeRunning) {
@@ -64,9 +68,27 @@ export class Daemon<Manifest extends T.SDKManifest> extends Drop {
.term({ keepSubcontainer: true })
.catch((err) => console.error(err))
this.commandController = await this.startCommand()
await this.commandController
.wait({ keepSubcontainer: true })
.catch((err) => console.error(err))
if (
this.oneshot &&
(await this.commandController.wait({ keepSubcontainer: true }).then(
(_) => true,
(err) => {
console.error(err)
return false
},
))
) {
for (const fn of this.onExitSuccessFns) {
try {
fn()
} catch (e) {
console.error("EXIT_SUCCESS handler", e)
}
}
this.onExitSuccessFns = []
this.exitedSuccess = true
break
}
await new Promise((resolve) => setTimeout(resolve, timeoutCounter))
timeoutCounter += TIMEOUT_INCREMENT_MS
timeoutCounter = Math.min(MAX_TIMEOUT_MS, timeoutCounter)

View File

@@ -16,6 +16,7 @@ import { HealthDaemon } from "./HealthDaemon"
import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController"
import { HealthCheck } from "../health/HealthCheck"
import { Oneshot } from "./Oneshot"
export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
@@ -48,29 +49,41 @@ export type Ready = {
trigger?: Trigger
}
type DaemonsParams<
type NewDaemonParams<Manifest extends T.SDKManifest> = {
/** The command line command to start the daemon */
command: T.CommandType
/** Information about the subcontainer in which the daemon runs */
subcontainer: SubContainer<Manifest>
runAsInit?: boolean
env?: Record<string, string>
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
}
type AddDaemonParams<
Manifest extends T.SDKManifest,
Ids extends string,
Id extends string,
> =
| {
/** The command line command to start the daemon */
command: T.CommandType
/** Information about the subcontainer in which the daemon runs */
subcontainer: SubContainer<Manifest>
env?: Record<string, string>
ready: Ready
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
}
> = (
| NewDaemonParams<Manifest>
| {
daemon: Daemon<Manifest>
ready: Ready
requires: Exclude<Ids, Id>[]
}
) & {
ready: Ready
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
}
type AddOneshotParams<
Manifest extends T.SDKManifest,
Ids extends string,
Id extends string,
> = NewDaemonParams<Manifest> & {
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
}
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
@@ -138,8 +151,8 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
/**
* Returns the complete list of daemons, including the one defined here
* @param id
* @param newDaemon
* @returns
* @param options
* @returns a new Daemons object
*/
addDaemon<Id extends string>(
// prettier-ignore
@@ -148,7 +161,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
ErrorDuplicateId<Id> extends Id ? never :
Id extends Ids ? ErrorDuplicateId<Id> :
Id,
options: DaemonsParams<Manifest, Ids, Id>,
options: AddDaemonParams<Manifest, Ids, Id>,
) {
const daemon =
"daemon" in options
@@ -180,6 +193,55 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
)
}
/**
* Returns the complete list of daemons, including a "oneshot" daemon one defined here
* a oneshot daemon is a command that executes once when started, and is considered "running" once it exits successfully
* @param id
* @param options
* @returns a new Daemons object
*/
addOneshot<Id extends string>(
id: "" extends Id
? never
: ErrorDuplicateId<Id> extends Id
? never
: Id extends Ids
? ErrorDuplicateId<Id>
: Id,
options: AddOneshotParams<Manifest, Ids, Id>,
) {
const daemon = Oneshot.of()(
this.effects,
options.subcontainer,
options.command,
{
...options,
},
)
const healthDaemon = new HealthDaemon(
daemon,
options.requires
.map((x) => this.ids.indexOf(x))
.filter((x) => x >= 0)
.map((id) => this.healthDaemons[id]),
id,
this.ids,
"EXIT_SUCCESS",
this.effects,
)
const daemons = this.daemons.concat(daemon)
const ids = [...this.ids, id] as (Ids | Id)[]
const healthDaemons = [...this.healthDaemons, healthDaemon]
return new Daemons<Manifest, Ids | Id>(
this.effects,
this.started,
daemons,
ids,
healthDaemons,
this.healthChecks,
)
}
async term() {
try {
this.healthChecks.forEach((health) => health.stop())

View File

@@ -5,6 +5,7 @@ import { Daemon } from "./Daemon"
import { SetHealth, Effects, SDKManifest } from "../../../base/lib/types"
import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../../../base/lib/util/asError"
import { Oneshot } from "./Oneshot"
const oncePromise = <T>() => {
let resolve: (value: T) => void
@@ -14,6 +15,8 @@ const oncePromise = <T>() => {
return { resolve: resolve!, promise }
}
export const EXIT_SUCCESS = "EXIT_SUCCESS" as const
/**
* Wanted a structure that deals with controlling daemons by their health status
* States:
@@ -33,7 +36,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
private readonly dependencies: HealthDaemon<Manifest>[],
readonly id: string,
readonly ids: string[],
readonly ready: Ready,
readonly ready: Ready | typeof EXIT_SUCCESS,
readonly effects: Effects,
) {
this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve))
@@ -87,6 +90,14 @@ export class HealthDaemon<Manifest extends SDKManifest> {
this.healthCheckCleanup?.()
}
private async setupHealthCheck() {
if (this.ready === "EXIT_SUCCESS") {
if (this.daemon instanceof Oneshot) {
this.daemon.onExitSuccess(() =>
this.setHealth({ result: "success", message: null }),
)
}
return
}
if (this.healthCheckCleanup) return
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
lastResult: this._health.result,
@@ -96,6 +107,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
done: true
}>()
new Promise(async () => {
if (this.ready === "EXIT_SUCCESS") return
for (
let res = await Promise.race([status, trigger.next()]);
!res.done;
@@ -142,6 +154,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
private async setHealth(health: HealthCheckResult) {
this._health = health
if (this.ready === "EXIT_SUCCESS") return
this.healthWatchers.forEach((watcher) => watcher())
const display = this.ready.display
if (!display) {
@@ -168,7 +181,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
}
async init() {
if (this.ready.display) {
if (this.ready !== "EXIT_SUCCESS" && this.ready.display) {
this.effects.setHealth({
id: this.id,
message: null,

View File

@@ -0,0 +1,49 @@
import * as T from "../../../base/lib/types"
import { SubContainer } from "../util/SubContainer"
import { CommandController } from "./CommandController"
import { Daemon } from "./Daemon"
/**
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running
* and the others state of running, where it will keep a living running command
* unlike Daemon, does not restart on success
*/
export class Oneshot<Manifest extends T.SDKManifest> extends Daemon<Manifest> {
static of<Manifest extends T.SDKManifest>() {
return async (
effects: T.Effects,
subcontainer: SubContainer<Manifest>,
command: T.CommandType,
options: {
env?:
| {
[variable: string]: string
}
| undefined
cwd?: string | undefined
user?: string | undefined
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
sigtermTimeout?: number
},
) => {
const startCommand = () =>
CommandController.of<Manifest>()(
effects,
subcontainer,
command,
options,
)
return new Oneshot(startCommand, true, [])
}
}
onExitSuccess(fn: () => void) {
if (this.exitedSuccess) {
fn()
} else {
this.onExitSuccessFns.push(fn)
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.12",
"version": "0.4.0-beta.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.12",
"version": "0.4.0-beta.13",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.12",
"version": "0.4.0-beta.13",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",