overlay-factory-worker 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/overlay-worker.ts +33 -1
- package/scripts/worker-lock.ts +106 -0
package/package.json
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* where that worker's installer already saved it. Set OVERLAY_WORKER_TOKEN to
|
|
13
13
|
* override, or get a fresh token from /dashboard/overlay.
|
|
14
14
|
*/
|
|
15
|
-
import { upload } from "@vercel/blob/client";
|
|
16
15
|
import { execFileSync } from "node:child_process";
|
|
17
16
|
import {
|
|
18
17
|
existsSync,
|
|
@@ -54,6 +53,30 @@ import { resolveCustomFonts, type ResolvedFont } from "./custom-fonts";
|
|
|
54
53
|
import { resolveFieldImages } from "./field-images";
|
|
55
54
|
import { autoRestock, whatPrintsRestock } from "./wp-restock";
|
|
56
55
|
import { ensureStateLinks } from "./state-dir";
|
|
56
|
+
import { acquireWorkerLock } from "./worker-lock";
|
|
57
|
+
|
|
58
|
+
// Worker-side upload, same signature as the old `upload` from
|
|
59
|
+
// "@vercel/blob/client": the upload route (worker token in clientPayload)
|
|
60
|
+
// hands back a signed PUT URL for Goose Tools' R2 bucket; the bytes go
|
|
61
|
+
// straight there.
|
|
62
|
+
async function upload(
|
|
63
|
+
pathname: string,
|
|
64
|
+
bytes: Buffer,
|
|
65
|
+
opts: { handleUploadUrl: string; clientPayload?: string; contentType?: string; access?: string; multipart?: boolean },
|
|
66
|
+
): Promise<{ url: string; pathname: string }> {
|
|
67
|
+
const contentType = opts.contentType ?? "application/octet-stream";
|
|
68
|
+
const res = await fetch(opts.handleUploadUrl, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ pathname, contentType, size: bytes.length, clientPayload: opts.clientPayload ?? null }),
|
|
72
|
+
});
|
|
73
|
+
const grant = (await res.json().catch(() => ({}))) as { uploadUrl?: string; url?: string; pathname?: string; error?: string };
|
|
74
|
+
if (!res.ok || !grant.uploadUrl || !grant.url) throw new Error(grant.error ?? `upload not allowed (${res.status})`);
|
|
75
|
+
const put = await fetch(grant.uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: new Uint8Array(bytes) });
|
|
76
|
+
if (!put.ok) throw new Error(`upload PUT failed (${put.status})`);
|
|
77
|
+
return { url: grant.url, pathname: grant.pathname ?? pathname };
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
|
|
58
81
|
// Installed from npm, the writable directories under ROOT are symlinks into
|
|
59
82
|
// ~/.goosetools/overlay. Re-made on every start: `update` reinstalls the
|
|
@@ -62,6 +85,15 @@ import { ensureStateLinks } from "./state-dir";
|
|
|
62
85
|
// anything reads or writes through one of those paths.
|
|
63
86
|
ensureStateLinks();
|
|
64
87
|
|
|
88
|
+
// One worker of each kind per machine. This one collides the most easily: the
|
|
89
|
+
// daemon runs the installed package while a checkout is the natural place to
|
|
90
|
+
// try a change, and the two have different state directories behind them.
|
|
91
|
+
acquireWorkerLock("overlay", {
|
|
92
|
+
label: "The Overlay Factory worker",
|
|
93
|
+
stopHint:
|
|
94
|
+
"launchctl bootout gui/$(id -u)/com.goosetools.overlay (or close its terminal)",
|
|
95
|
+
});
|
|
96
|
+
|
|
65
97
|
/**
|
|
66
98
|
* Where the Goose Tools installer keeps this machine's worker credentials.
|
|
67
99
|
* Reading it means the usual case needs no environment variables at all.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// One worker of each kind per machine.
|
|
2
|
+
//
|
|
3
|
+
// Nothing stops you starting a second worker: the daemon runs in the
|
|
4
|
+
// background, and `run` in a terminal is the normal way to watch one work or
|
|
5
|
+
// to try a change from a checkout. Both then poll the same queue with the same
|
|
6
|
+
// token.
|
|
7
|
+
//
|
|
8
|
+
// The server is safe — claiming a job is a single compare-and-set, so two
|
|
9
|
+
// workers never get the same one. The damage is quieter than that. They SPLIT
|
|
10
|
+
// the queue, so jobs land on whichever copy happened to pick them up: half
|
|
11
|
+
// from the code you're editing, half from the installed release, with a
|
|
12
|
+
// different state directory behind each. And the polling doubles, which is
|
|
13
|
+
// what the idle interval exists to keep down in the first place.
|
|
14
|
+
//
|
|
15
|
+
// So: whoever gets here first holds the lock, and the second one stands down
|
|
16
|
+
// with an explanation instead of quietly competing.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
const LOCK_DIR = join(homedir(), ".goosetools", "locks");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
|
|
27
|
+
* who has it and exit. Returns nothing — it either succeeds or ends the
|
|
28
|
+
* process.
|
|
29
|
+
*
|
|
30
|
+
* `stopHint` is the command that stops the OTHER copy, and it's the whole
|
|
31
|
+
* point of the message: "already running" without it just moves the puzzle.
|
|
32
|
+
*/
|
|
33
|
+
export function acquireWorkerLock(
|
|
34
|
+
name: string,
|
|
35
|
+
{ label, stopHint }: { label: string; stopHint: string },
|
|
36
|
+
): void {
|
|
37
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
38
|
+
const file = join(LOCK_DIR, `${name}.pid`);
|
|
39
|
+
|
|
40
|
+
const holder = readHolder(file);
|
|
41
|
+
if (holder && isAlive(holder.pid)) {
|
|
42
|
+
console.log(
|
|
43
|
+
`\n${label} is already running on this computer (pid ${holder.pid}${
|
|
44
|
+
holder.since ? `, since ${holder.since}` : ""
|
|
45
|
+
}).\n\n` +
|
|
46
|
+
"Two of them would split the queue between them — some jobs done by\n" +
|
|
47
|
+
"one copy, some by the other. Stopping here instead.\n\n" +
|
|
48
|
+
` Stop the other one: ${stopHint}\n`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Either no lock, or one left behind by a worker that was killed. Both are
|
|
54
|
+
// ours to take: an O_EXCL create loses to a worker that beat us here by
|
|
55
|
+
// milliseconds, which is the one race worth caring about.
|
|
56
|
+
if (holder) rmSync(file, { force: true });
|
|
57
|
+
let fd: number;
|
|
58
|
+
try {
|
|
59
|
+
fd = openSync(file, "wx");
|
|
60
|
+
} catch {
|
|
61
|
+
console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
|
|
67
|
+
const release = () => rmSync(file, { force: true });
|
|
68
|
+
process.on("exit", release);
|
|
69
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
70
|
+
process.on(sig, () => {
|
|
71
|
+
release();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readHolder(file: string): { pid: number; since: string | null } | null {
|
|
78
|
+
if (!existsSync(file)) return null;
|
|
79
|
+
try {
|
|
80
|
+
const [pid, since] = readFileSync(file, "utf8").split("\n");
|
|
81
|
+
const n = Number.parseInt(pid, 10);
|
|
82
|
+
return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
|
|
89
|
+
// pid alive" isn't enough on its own. Checking that it's a node process is
|
|
90
|
+
// cheap and rules out the reuse case that would otherwise lock out the daemon
|
|
91
|
+
// until someone deleted the file by hand.
|
|
92
|
+
function isAlive(pid: number): boolean {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
102
|
+
}));
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|