The other half of the same report. The pill came back after a refresh, and then a
`pm2 restart officer` killed the dev server outright — because `restartBackgroundTask`
spawned it as a plain child of the platform. A job the AGENT starts is a child of the
`officer-claude-code` sidecar, a PM2 peer, so those were never affected. The tray
therefore showed two chips that looked identical and behaved differently, and the
restart button's own help text promised the opposite of what happened.
Measured against a throwaway PM2 app rather than reasoned about, because the two
plausible causes call for different fixes — a signal to the process GROUP, or a walk
of the process TREE:
plain `sh -c` killed
`setsid sh -c` killed <- a new session is NOT enough
`setsid --fork sh -c` survived
PM2 walks the tree by ppid, so a new session leaves the job a child and it is found
anyway. `--fork` is what matters: setsid forks and its parent exits at once, so the
job is reparented to init before any restart can enumerate it.
── What that costs, and how it is paid ──
The handle `Bun.spawn` returns is now the setsid parent, which exits immediately.
Waiting on it reported every job as finished the moment it started, so exit detection
moved to `watchForExit`: poll who holds the output file, and when nobody does, read
the status the wrapper recorded. That is the same liveness question Stop and Pause
already ask, so it adds no second notion of "running" — and a paused job still holds
its file, so SIGSTOP correctly reads as running rather than as an exit.
Two details that would each have been a bug:
- A GRACE PERIOD. Between the fork and the job opening its output there is a moment
with no holders; polling into it announces a completion for a job that never ran.
- The command is a POSITIONAL PARAMETER to the wrapper, not interpolated into it.
Interpolation would let a command containing quotes terminate the wrapper's own
quoting. Tested with `echo "quoted 'both' ways"`.
A missing status file is reported as a clean finish, not a failure: that is what Stop
looks like, and "you stopped it" is nearer the truth than "it crashed".
Also corrected the button text. It said background jobs survive "because they are
their own PM2 processes" — true of agent-started jobs, false of restarted ones, and
now the wrong reason for both. They survive because they are outside this process's
tree.
── Verified ──
End-to-end through `buildHonoApp`, on a synthetic task:
POST /restart -> 200
holders: pid 906186 (ppid 1, session leader), pid 906188 (ppid 906186)
probe process was 906173 — in neither chain
quotes survived the wrapper
durable events: task:started -> task:notification (completed), exit file "0"
My own assertion in that probe was wrong and the output is what corrected it: it
demanded ppid 1 of EVERY holder, but only the session root is reparented — its own
children hang off it, which is the point.
Not in `bun test`: nothing in the suite touches Postgres or spawns processes.
tsgo clean, 919 pass / 7 fail unchanged. Needs a server restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
5.8 KiB
TypeScript
132 lines
5.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { Hammer, Loader2, RotateCw } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
|
|
/**
|
|
* The two ways to load a change without leaving the browser.
|
|
*
|
|
* They look like a pair and they are not, which is the whole content of this screen. One is free; the
|
|
* other costs you every open connection. Presenting them identically would invite the reflex of pressing
|
|
* the second when the first would have done.
|
|
*/
|
|
/**
|
|
* The message a route meant to send, out of what the client threw.
|
|
*
|
|
* `useClient` throws with the raw response BODY as its message, and these routes answer with JSON. So the
|
|
* useful sentence is one `JSON.parse` inside a string nobody should be asked to read.
|
|
*/
|
|
export function errorMessage(err: unknown, fallback: string): string {
|
|
const raw = err instanceof Error ? err.message : '';
|
|
if (!raw) return fallback;
|
|
try {
|
|
const parsed = JSON.parse(raw) as { error?: unknown };
|
|
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
|
} catch {
|
|
// Not JSON — a proxy error page, or a plain string. Whatever it is, it is what the server said.
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export const MaintenanceSection = () => {
|
|
const client = useClient();
|
|
const [rebuilding, setRebuilding] = useState(false);
|
|
const [restarting, setRestarting] = useState(false);
|
|
const [lastBuild, setLastBuild] = useState<string | null>(null);
|
|
|
|
const rebuild = async () => {
|
|
setRebuilding(true);
|
|
try {
|
|
const res = await client.post<{ ok: boolean; ms: number; outputs: number; error?: string }>(
|
|
'/server-settings/rebuild-frontend',
|
|
{},
|
|
);
|
|
if (!res.ok) throw new Error(res.error ?? 'Build failed');
|
|
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
|
|
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
|
|
// the difference between "it worked" and "why does my change not show".
|
|
toast.success('Frontend rebuilt — refresh this page to see it');
|
|
} catch (err) {
|
|
// The client throws on any status >= 400 with the raw response body as its message, so a build
|
|
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
|
|
// `{"ok":false,"error":"…"}` and makes them dig the real message out of punctuation.
|
|
toast.error(errorMessage(err, 'Rebuild failed'));
|
|
} finally {
|
|
setRebuilding(false);
|
|
}
|
|
};
|
|
|
|
const restart = async () => {
|
|
setRestarting(true);
|
|
try {
|
|
await client.post('/server-settings/restart', {});
|
|
toast.success('Restarting — the page will reconnect on its own');
|
|
} catch (err) {
|
|
/**
|
|
* Two failures arrive here and they are opposites.
|
|
*
|
|
* A REFUSAL — the route answering 400 because PM2 does not manage this process — is a real error
|
|
* with a status, and nothing is restarting. A DROPPED CONNECTION has no status, and is the expected
|
|
* shape of success: the process answering the request is the one being replaced, so it often dies
|
|
* before the response lands.
|
|
*
|
|
* The first version treated both as success. It would have told you "restarting" while refusing to
|
|
* restart, and left the button disabled forever — which is precisely the case this screen exists to
|
|
* make legible.
|
|
*/
|
|
const status = (err as { status?: number }).status;
|
|
if (typeof status === 'number') {
|
|
toast.error(errorMessage(err, 'Restart refused'));
|
|
setRestarting(false);
|
|
return;
|
|
}
|
|
toast.success('Restarting — the page will reconnect on its own');
|
|
}
|
|
// Left disabled on the success path, deliberately: the button must not become pressable again while
|
|
// the server is coming back.
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex items-start justify-between gap-6">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
|
|
refresh the page when it finishes.
|
|
</p>
|
|
{lastBuild && <p className="mt-1 text-xs text-muted-foreground/70">Last build: {lastBuild}</p>}
|
|
</div>
|
|
<Button disabled={rebuilding || restarting} onClick={() => void rebuild()} className="shrink-0">
|
|
{rebuilding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Hammer className="mr-2 h-4 w-4" />}
|
|
{rebuilding ? 'Building…' : 'Rebuild'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-foreground">Restart the server</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
|
|
cannot see an edit to the backend.
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Costs every open connection: websockets drop and in-memory chat sessions are lost. Background jobs survive —
|
|
they run outside this process’s tree.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={rebuilding || restarting}
|
|
onClick={() => void restart()}
|
|
className="shrink-0"
|
|
>
|
|
{restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCw className="mr-2 h-4 w-4" />}
|
|
{restarting ? 'Restarting…' : 'Restart'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|