Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ export class DockerProcedureContainer extends Drop {
timeoutMs?: number | null,
) {
try {
return await this.subcontainer.exec(commands, options, timeoutMs)
return await this.subcontainer.exec(commands, {
...options,
timeout: timeoutMs,
})
} finally {
await this.subcontainer.destroy?.()
}
Expand All @@ -150,7 +153,10 @@ export class DockerProcedureContainer extends Drop {
options?: CommandOptions & ExecOptions,
) {
try {
const res = await this.subcontainer.exec(commands, options, timeoutMs)
const res = await this.subcontainer.exec(commands, {
...options,
timeout: timeoutMs,
})
if (res.exitCode !== 0) {
const codeOrSignal =
res.exitCode !== null
Expand Down
58 changes: 58 additions & 0 deletions projects/start-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
a package built with this SDK now writes as its manifest `osVersion` — so the
registry offers that package to servers on 0.4.0 or later

- **`SubContainer.exec` / `execFail` take `timeout` and `abort` as named
options rather than as third and fourth positional arguments.**
`sub.execFail(cmd, { user: 'root' }, null)` becomes
`sub.execFail(cmd, { user: 'root', timeout: null })`. A bare `null` in the
third position gave no hint which of the two knobs it was setting or what it
meant, and reaching the fourth argument meant supplying the third. Both moved
together because dropping only `timeout` would have left `abort` sliding
into a position whose type it does not match. Passing either positionally is
now a compile error, so anything that needs updating says so at build time

### Added

- **`sdk.getRootCa(effects)` returns this server's root CA certificate.** A
Expand Down Expand Up @@ -75,6 +85,54 @@
one, so the guard only duplicated that check while giving the filename a
second place to go stale. Cosmetic — no build ever failed over it

- **A database dump backup or restore is no longer killed after exactly thirty
seconds.** Steps in `Backups.withPgDump` / `Backups.withMysqlDump` run under
`SubContainer.exec`'s default 30 s cap unless they opt out, and 1.5.2 — which
began staging the dump in `/tmp` and copying it to the backup target, rather
than dumping onto the target directly — left the opt-out on `pg_dump` and
gave the new `cp` none. The copy's duration is set by the size of the dump and
the speed of the target. So a backup that had succeeded for months began
failing the first time that copy crossed thirty seconds, with nothing to point
at the cause:

```
Failed: Unknown Error: Error: cp terminated with signal SIGKILL:
```

Restore stages the dump off the target through the same kind of copy, under
the same cap — so a database whose dump took longer than thirty seconds to
copy could not be restored at all, which is discovered during recovery, when
the backup is all the user has. Every step of a dump or restore whose
duration follows the data now opts out: both copies, `pg_ctl start` and
`pg_ctl stop` (which wait on the cluster's crash recovery and its shutdown
checkpoint), the recursive `chown`s over the data directory, `initdb`,
`mysql_install_db` / `mysqld --initialize-insecure`, and the foreground
`mysqld` MariaDB runs for the length of the dump.

The two `pg_ctl` steps keep a bound, because `pg_ctl` has its own: `-w` is its
default and it gives up after `-t` seconds, which was 60 regardless of what
the SDK allowed. `PgDumpConfig.readyTimeout` — the knob 2.0.1 added for
clusters that need longer — now supplies that `-t`, so raising it reaches the
step that actually blocks instead of stopping at the readiness poll. Its
default is 60 000 ms, which is `pg_ctl`'s own default, so nothing changes
until you raise it. Fixes
[#3636](https://github.com/Start9Labs/start-technologies/issues/3636)

- **A command killed by `SubContainer.exec`'s own timeout now says that it timed
out.** The error was built from the signal alone, so the SDK's timer read
exactly like an OOM kill, a cgroup kill, or an operator's `kill -9` — and
since `cp` writes nothing to stderr when it is killed, the whole user-facing
notification was `cp terminated with signal SIGKILL:`. The message now names
the timeout and the limit that elapsed:

```
cp timed out after 30000ms and was killed with SIGKILL:
```

`exec`'s result carries `timedOutAfter` — the limit that fired, or `null`
when the process was not killed by this timer — alongside `exitCode` and
`exitSignal`

## 2.0.9 — StartOS 0.4.0-beta.10 (2026-07-25)

### Fixed
Expand Down
22 changes: 21 additions & 1 deletion projects/start-sdk/docs/src/main.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ if (result.exitCode !== 0) {

// execFail() - throws on error (good for required commands)
// Uses the default user from the Dockerfile (no need to specify { user: '...' })
await appSub.execFail(['git', 'clone', 'https://github.com/user/repo.git'])
await appSub.execFail(['myapp', 'check-config'])

// Override user when needed (e.g., run as root)
await appSub.exec(['update-ca-certificates'], { user: 'root' })
Expand All @@ -497,6 +497,26 @@ The `user` option is optional. If omitted, commands run as the default user defi
- You need to inspect the exit code or output regardless of success/failure
- You want custom error handling logic

### Commands That Run Longer Than 30 Seconds

`exec` and `execFail` take a `timeout` option: how long the SDK waits before it gives up and fails the call. It defaults to **30 s**, so a command that legitimately takes longer — cloning a large repository, importing a database, copying a multi-gigabyte file — fails partway through unless you say otherwise. Pass `null` to wait as long as it takes:

```typescript
// Gives up after 30 s — fine for a command that either answers quickly or is stuck
await appSub.execFail(['update-ca-certificates'], { user: 'root' })

// No limit — takes as long as the database takes
await appSub.execFail(['pg_restore', '-U', user, '-d', database, dumpFile], {
user: 'postgres',
timeout: null,
})
```

Opt out whenever the runtime is set by something you cannot bound: the size of the data, the speed of a disk or backup target, or another process you are waiting on. Keep the default for commands that should answer promptly, where the timeout is what stops a wedged container from hanging the service.

> [!NOTE]
> On timeout the SDK sends `SIGKILL` to the process it spawned and reports `timed out after <n>ms and was killed with SIGKILL`; `exec()`'s result carries `timedOutAfter`, set to the limit that elapsed. The command itself runs inside the subcontainer and is not signalled — it stops when the subcontainer is torn down, so treat a timeout as "the SDK stopped waiting", not "the work stopped".

## PostgreSQL Sidecar

Many services require a PostgreSQL database. Run it as a sidecar daemon within the same service package.
Expand Down
Loading