quidproquo-cli 0.1.17 → 0.1.19
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/lib/commonjs/commands/goDevApi.js +36 -3
- package/lib/commonjs/commands/goDevApi.js.map +1 -1
- package/lib/commonjs/lib/killChildWithEscalation.d.ts +10 -0
- package/lib/commonjs/lib/killChildWithEscalation.js +33 -0
- package/lib/commonjs/lib/killChildWithEscalation.js.map +1 -0
- package/lib/commonjs/lib/killStaleListeners.js +27 -4
- package/lib/commonjs/lib/killStaleListeners.js.map +1 -1
- package/lib/esm/commands/goDevApi.js +36 -3
- package/lib/esm/commands/goDevApi.js.map +1 -1
- package/lib/esm/lib/killChildWithEscalation.d.ts +10 -0
- package/lib/esm/lib/killChildWithEscalation.js +29 -0
- package/lib/esm/lib/killChildWithEscalation.js.map +1 -0
- package/lib/esm/lib/killStaleListeners.js +27 -4
- package/lib/esm/lib/killStaleListeners.js.map +1 -1
- package/package.json +10 -10
|
@@ -17,6 +17,10 @@ exports.goDevApiCommand = void 0;
|
|
|
17
17
|
// generates the dev-server entry, builds the rspack config, then runs rspack
|
|
18
18
|
// in watch mode, restarting the server process on every successful rebuild.
|
|
19
19
|
//
|
|
20
|
+
// A restart is not instant: the server drains in-flight work and checkpoints
|
|
21
|
+
// its stores on the way out (typically tens of ms, 5s worst case), and this
|
|
22
|
+
// command waits for that before starting the replacement.
|
|
23
|
+
//
|
|
20
24
|
// The one thing NOT hot-reloaded is the qpq configs themselves: each service's
|
|
21
25
|
// infrastructure.ts is evaluated once at launch and baked into the
|
|
22
26
|
// `quidproquo-dynamic-loader` virtual module — config changes need a full
|
|
@@ -28,6 +32,7 @@ const core_1 = require("@rspack/core");
|
|
|
28
32
|
const deployEnv_1 = require("../lib/deployEnv");
|
|
29
33
|
const devServerEntry_1 = require("../lib/devServerEntry");
|
|
30
34
|
const discovery_1 = require("../lib/discovery");
|
|
35
|
+
const killChildWithEscalation_1 = require("../lib/killChildWithEscalation");
|
|
31
36
|
const killStaleListeners_1 = require("../lib/killStaleListeners");
|
|
32
37
|
const resolveAppSelection_1 = require("../lib/resolveAppSelection");
|
|
33
38
|
// 8080/8888 are set in the generated entry; 3001 is the quidproquo-dev-server
|
|
@@ -76,7 +81,10 @@ const goDevApiCommand = (argv) => __awaiter(void 0, void 0, void 0, function* ()
|
|
|
76
81
|
restartQueued = false;
|
|
77
82
|
startServer();
|
|
78
83
|
});
|
|
79
|
-
|
|
84
|
+
// Not a bare kill: the server drains in-flight work before it exits, so it
|
|
85
|
+
// needs asking properly and then a hard backstop if it wedges. Waiting for
|
|
86
|
+
// the exit is what keeps the ports free for the replacement.
|
|
87
|
+
void (0, killChildWithEscalation_1.killChildWithEscalation)(child);
|
|
80
88
|
};
|
|
81
89
|
console.log('Bundling dev server (watch mode)...');
|
|
82
90
|
let lastHash;
|
|
@@ -100,11 +108,36 @@ const goDevApiCommand = (argv) => __awaiter(void 0, void 0, void 0, function* ()
|
|
|
100
108
|
console.log(child ? 'Rebuilt. Restarting dev server...' : 'Dev server bundled. Starting...');
|
|
101
109
|
restartServer();
|
|
102
110
|
});
|
|
103
|
-
|
|
111
|
+
// Wait for the server to finish draining before tearing the watcher down and
|
|
112
|
+
// exiting: closing the compiler first would exit the parent while the child
|
|
113
|
+
// is still writing, which is the race this whole path exists to close.
|
|
114
|
+
//
|
|
115
|
+
// SIGTERM as well as SIGINT, and it is load-bearing rather than symmetry for
|
|
116
|
+
// its own sake. This process is a WRAPPER: the dev server that handles
|
|
117
|
+
// signals and drains its work is the child it spawned. Without a handler
|
|
118
|
+
// here, SIGTERM (what `docker stop` sends, and what any script stopping this
|
|
119
|
+
// programmatically sends) kills the wrapper outright on the default
|
|
120
|
+
// disposition, the child never gets asked to stop, and every guarantee the
|
|
121
|
+
// dev server's shutdown sequence makes is bypassed. go:dev:web already
|
|
122
|
+
// handles both.
|
|
123
|
+
let shuttingDown = false;
|
|
124
|
+
const handleShutdownSignal = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
125
|
+
if (shuttingDown) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
shuttingDown = true;
|
|
104
129
|
restartQueued = true; // suppress the crash log / rebuild-restart on our own kill
|
|
105
|
-
|
|
130
|
+
if (child) {
|
|
131
|
+
yield (0, killChildWithEscalation_1.killChildWithEscalation)(child);
|
|
132
|
+
}
|
|
106
133
|
compiler.close(() => process.exit(0));
|
|
107
134
|
});
|
|
135
|
+
process.on('SIGINT', () => {
|
|
136
|
+
void handleShutdownSignal();
|
|
137
|
+
});
|
|
138
|
+
process.on('SIGTERM', () => {
|
|
139
|
+
void handleShutdownSignal();
|
|
140
|
+
});
|
|
108
141
|
});
|
|
109
142
|
exports.goDevApiCommand = goDevApiCommand;
|
|
110
143
|
//# sourceMappingURL=goDevApi.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"goDevApi.js","sourceRoot":"","sources":["../../../src/commands/goDevApi.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,uEAAuE;AACvE,6EAA6E;AAC7E,4EAA4E;AAC5E,EAAE;AACF,+EAA+E;AAC/E,mEAAmE;AACnE,0EAA0E;AAC1E,wBAAwB;AACxB,uEAA6F;AAE7F,iDAAoD;AAEpD,gDAAwB;AACxB,uCAAsC;AAEtC,gDAA4D;AAC5D,0DAA4D;AAC5D,gDAA2C;AAC3C,kEAAyF;AACzF,oEAAiE;AAEjE,8EAA8E;AAC9E,0CAA0C;AAC1C,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AAC5C,MAAM,sBAAsB,GAAG,cAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC;AAE1E,MAAM,eAAe,GAAG,CAAO,IAAc,EAAiB,EAAE;IACrE,MAAM,IAAI,GAAG,IAAA,mBAAO,GAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,IAAA,yCAAmB,EAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;IAC3E,OAAO,CAAC,GAAG,CAAC,WAAW,GAAG,OAAO,CAAC;IAClC,IAAA,oCAAwB,EAAC,OAAO,CAAC,CAAC;IAClC,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,GAAG,CAAC,CAAC;IAE/C,sEAAsE;IACtE,uEAAuE;IACvE,+DAA+D;IAC/D,IAAA,6CAAwB,EAAC,IAAI,CAAC,CAAC;IAC/B,IAAA,uCAAkB,EAAC,gBAAgB,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC;IAE5F,MAAM,UAAU,GAAG,IAAA,kDAAuB,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC1D,MAAM,KAAK,GAAG,IAAA,oCAAmB,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACjD,MAAM,YAAY,GAAG,IAAA,mDAAwB,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;IAC3E,MAAM,UAAU,GAAG,cAAI,CAAC,IAAI,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC;IAC3D,MAAM,GAAG,mCAAQ,OAAO,CAAC,GAAG,KAAE,WAAW,EAAE,OAAO,GAAE,CAAC;IAErD,8EAA8E;IAC9E,IAAI,KAAK,GAAwB,IAAI,CAAC;IACtC,IAAI,aAAa,GAAG,KAAK,CAAC;IAE1B,MAAM,WAAW,GAAG,GAAS,EAAE;QAC7B,KAAK,GAAG,IAAA,qBAAK,EAAC,MAAM,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC;QAC/D,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACxB,KAAK,GAAG,IAAI,CAAC;YACb,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,sEAAsE;gBACtE,uDAAuD;gBACvD,OAAO,CAAC,GAAG,CAAC,+BAA+B,IAAI,8BAA8B,CAAC,CAAC;YACjF,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC,CAAC;IAEF,MAAM,aAAa,GAAG,GAAS,EAAE;QAC/B,IAAI,aAAa;YAAE,OAAO;QAC1B,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,WAAW,EAAE,CAAC;YACd,OAAO;QACT,CAAC;QACD,aAAa,GAAG,IAAI,CAAC;QACrB,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;YACtB,aAAa,GAAG,KAAK,CAAC;YACtB,WAAW,EAAE,CAAC;QAChB,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"goDevApi.js","sourceRoot":"","sources":["../../../src/commands/goDevApi.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,uEAAuE;AACvE,6EAA6E;AAC7E,4EAA4E;AAC5E,EAAE;AACF,6EAA6E;AAC7E,4EAA4E;AAC5E,0DAA0D;AAC1D,EAAE;AACF,+EAA+E;AAC/E,mEAAmE;AACnE,0EAA0E;AAC1E,wBAAwB;AACxB,uEAA6F;AAE7F,iDAAoD;AAEpD,gDAAwB;AACxB,uCAAsC;AAEtC,gDAA4D;AAC5D,0DAA4D;AAC5D,gDAA2C;AAC3C,4EAAyE;AACzE,kEAAyF;AACzF,oEAAiE;AAEjE,8EAA8E;AAC9E,0CAA0C;AAC1C,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AAC5C,MAAM,sBAAsB,GAAG,cAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC;AAE1E,MAAM,eAAe,GAAG,CAAO,IAAc,EAAiB,EAAE;IACrE,MAAM,IAAI,GAAG,IAAA,mBAAO,GAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,IAAA,yCAAmB,EAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;IAC3E,OAAO,CAAC,GAAG,CAAC,WAAW,GAAG,OAAO,CAAC;IAClC,IAAA,oCAAwB,EAAC,OAAO,CAAC,CAAC;IAClC,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,GAAG,CAAC,CAAC;IAE/C,sEAAsE;IACtE,uEAAuE;IACvE,+DAA+D;IAC/D,IAAA,6CAAwB,EAAC,IAAI,CAAC,CAAC;IAC/B,IAAA,uCAAkB,EAAC,gBAAgB,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC,CAAC;IAE5F,MAAM,UAAU,GAAG,IAAA,kDAAuB,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC1D,MAAM,KAAK,GAAG,IAAA,oCAAmB,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACjD,MAAM,YAAY,GAAG,IAAA,mDAAwB,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;IAC3E,MAAM,UAAU,GAAG,cAAI,CAAC,IAAI,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC;IAC3D,MAAM,GAAG,mCAAQ,OAAO,CAAC,GAAG,KAAE,WAAW,EAAE,OAAO,GAAE,CAAC;IAErD,8EAA8E;IAC9E,IAAI,KAAK,GAAwB,IAAI,CAAC;IACtC,IAAI,aAAa,GAAG,KAAK,CAAC;IAE1B,MAAM,WAAW,GAAG,GAAS,EAAE;QAC7B,KAAK,GAAG,IAAA,qBAAK,EAAC,MAAM,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC;QAC/D,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACxB,KAAK,GAAG,IAAI,CAAC;YACb,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,sEAAsE;gBACtE,uDAAuD;gBACvD,OAAO,CAAC,GAAG,CAAC,+BAA+B,IAAI,8BAA8B,CAAC,CAAC;YACjF,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC,CAAC;IAEF,MAAM,aAAa,GAAG,GAAS,EAAE;QAC/B,IAAI,aAAa;YAAE,OAAO;QAC1B,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,WAAW,EAAE,CAAC;YACd,OAAO;QACT,CAAC;QACD,aAAa,GAAG,IAAI,CAAC;QACrB,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;YACtB,aAAa,GAAG,KAAK,CAAC;YACtB,WAAW,EAAE,CAAC;QAChB,CAAC,CAAC,CAAC;QAEH,2EAA2E;QAC3E,2EAA2E;QAC3E,6DAA6D;QAC7D,KAAK,IAAA,iDAAuB,EAAC,KAAK,CAAC,CAAC;IACtC,CAAC,CAAC;IAEF,OAAO,CAAC,GAAG,CAAC,qCAAqC,CAAC,CAAC;IACnD,IAAI,QAAmC,CAAC;IACxC,MAAM,QAAQ,GAAG,IAAA,aAAM,EAAC,YAAY,CAAC,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;QACvD,IAAI,GAAG,EAAE,CAAC;YACR,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACnB,OAAO;QACT,CAAC;QACD,IAAI,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,SAAS,EAAE,EAAE,CAAC;YACvB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;YAC/E,OAAO,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAAC;YACtF,OAAO;QACT,CAAC;QACD,2EAA2E;QAC3E,2DAA2D;QAC3D,IAAI,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,IAAI,MAAK,QAAQ,IAAI,KAAK,EAAE,CAAC;YACtC,OAAO;QACT,CAAC;QACD,QAAQ,GAAG,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,IAAI,CAAC;QACvB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAC,CAAC,iCAAiC,CAAC,CAAC;QAC7F,aAAa,EAAE,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,EAAE;IACF,6EAA6E;IAC7E,uEAAuE;IACvE,yEAAyE;IACzE,6EAA6E;IAC7E,oEAAoE;IACpE,2EAA2E;IAC3E,uEAAuE;IACvE,gBAAgB;IAChB,IAAI,YAAY,GAAG,KAAK,CAAC;IAEzB,MAAM,oBAAoB,GAAG,GAAwB,EAAE;QACrD,IAAI,YAAY,EAAE,CAAC;YACjB,OAAO;QACT,CAAC;QACD,YAAY,GAAG,IAAI,CAAC;QAEpB,aAAa,GAAG,IAAI,CAAC,CAAC,2DAA2D;QAEjF,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,IAAA,iDAAuB,EAAC,KAAK,CAAC,CAAC;QACvC,CAAC;QAED,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACxC,CAAC,CAAA,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;QACxB,KAAK,oBAAoB,EAAE,CAAC;IAC9B,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;QACzB,KAAK,oBAAoB,EAAE,CAAC;IAC9B,CAAC,CAAC,CAAC;AACL,CAAC,CAAA,CAAC;AAhHW,QAAA,eAAe,mBAgH1B","sourcesContent":["// `qpq go:dev:api` — boots the QPQ local dev server. Resolves the app,\n// generates the dev-server entry, builds the rspack config, then runs rspack\n// in watch mode, restarting the server process on every successful rebuild.\n//\n// A restart is not instant: the server drains in-flight work and checkpoints\n// its stores on the way out (typically tens of ms, 5s worst case), and this\n// command waits for that before starting the replacement.\n//\n// The one thing NOT hot-reloaded is the qpq configs themselves: each service's\n// infrastructure.ts is evaluated once at launch and baked into the\n// `quidproquo-dynamic-loader` virtual module — config changes need a full\n// `go:dev:api` restart.\nimport { getAppServiceQpqConfigs, getDevServerRspackConfig } from 'quidproquo-deploy-rspack';\n\nimport { ChildProcess, spawn } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport { rspack } from '@rspack/core';\n\nimport { primeDeployEnvFromConfig } from '../lib/deployEnv';\nimport { writeDevServerEntry } from '../lib/devServerEntry';\nimport { getRoot } from '../lib/discovery';\nimport { killChildWithEscalation } from '../lib/killChildWithEscalation';\nimport { killOtherQpqDevProcesses, killStaleListeners } from '../lib/killStaleListeners';\nimport { resolveAppSelection } from '../lib/resolveAppSelection';\n\n// 8080/8888 are set in the generated entry; 3001 is the quidproquo-dev-server\n// file-storage (secure URL) default port.\nconst DEV_SERVER_PORTS = [8080, 8888, 3001];\nconst DEV_SERVER_BUNDLE_PATH = path.join('dist', 'qpq', 'dev-server', 'main.js');\n\nexport const goDevApiCommand = async (argv: string[]): Promise<void> => {\n const root = getRoot();\n const appName = await resolveAppSelection({ argv, envVar: 'QPQ_DEV_APP' });\n process.env.QPQ_DEV_APP = appName;\n primeDeployEnvFromConfig(appName);\n console.log(`Dev server for app [${appName}]`);\n\n // Catches a lingering watcher from a previous run even if its spawned\n // child already exited (see killOtherQpqDevProcesses) — then the usual\n // port-based sweep for anything else still bound to our ports.\n killOtherQpqDevProcesses(root);\n killStaleListeners(DEV_SERVER_PORTS, (command) => command.includes(DEV_SERVER_BUNDLE_PATH));\n\n const qpqConfigs = getAppServiceQpqConfigs(root, appName);\n const entry = writeDevServerEntry(root, appName);\n const rspackConfig = getDevServerRspackConfig({ root, entry, qpqConfigs });\n const bundlePath = path.join(root, DEV_SERVER_BUNDLE_PATH);\n const env = { ...process.env, QPQ_DEV_APP: appName };\n\n // The long-lived server process (HTTP 8080 / WS 8888), restarted per rebuild.\n let child: ChildProcess | null = null;\n let restartQueued = false;\n\n const startServer = (): void => {\n child = spawn('node', [bundlePath], { stdio: 'inherit', env });\n child.on('exit', (code) => {\n child = null;\n if (!restartQueued) {\n // Crashed (or exited) on its own — leave it down; the next successful\n // rebuild (i.e. the next file save) brings it back up.\n console.log(`Dev server exited with code ${code}. Save a file to restart it.`);\n }\n });\n };\n\n const restartServer = (): void => {\n if (restartQueued) return;\n if (!child) {\n startServer();\n return;\n }\n restartQueued = true;\n child.once('exit', () => {\n restartQueued = false;\n startServer();\n });\n\n // Not a bare kill: the server drains in-flight work before it exits, so it\n // needs asking properly and then a hard backstop if it wedges. Waiting for\n // the exit is what keeps the ports free for the replacement.\n void killChildWithEscalation(child);\n };\n\n console.log('Bundling dev server (watch mode)...');\n let lastHash: string | null | undefined;\n const compiler = rspack(rspackConfig);\n compiler.watch({ aggregateTimeout: 200 }, (err, stats) => {\n if (err) {\n console.error(err);\n return;\n }\n if (stats?.hasErrors()) {\n console.error(stats.toString({ colors: true, chunks: false, modules: false }));\n console.log('Build failed — dev server not restarted. Fix the error and save again.');\n return;\n }\n // Skip no-op rebuilds (e.g. the virtual-module-triggered second compile at\n // startup): only restart when the output actually changed.\n if (stats?.hash === lastHash && child) {\n return;\n }\n lastHash = stats?.hash;\n console.log(child ? 'Rebuilt. Restarting dev server...' : 'Dev server bundled. Starting...');\n restartServer();\n });\n\n // Wait for the server to finish draining before tearing the watcher down and\n // exiting: closing the compiler first would exit the parent while the child\n // is still writing, which is the race this whole path exists to close.\n //\n // SIGTERM as well as SIGINT, and it is load-bearing rather than symmetry for\n // its own sake. This process is a WRAPPER: the dev server that handles\n // signals and drains its work is the child it spawned. Without a handler\n // here, SIGTERM (what `docker stop` sends, and what any script stopping this\n // programmatically sends) kills the wrapper outright on the default\n // disposition, the child never gets asked to stop, and every guarantee the\n // dev server's shutdown sequence makes is bypassed. go:dev:web already\n // handles both.\n let shuttingDown = false;\n\n const handleShutdownSignal = async (): Promise<void> => {\n if (shuttingDown) {\n return;\n }\n shuttingDown = true;\n\n restartQueued = true; // suppress the crash log / rebuild-restart on our own kill\n\n if (child) {\n await killChildWithEscalation(child);\n }\n\n compiler.close(() => process.exit(0));\n };\n\n process.on('SIGINT', () => {\n void handleShutdownSignal();\n });\n\n process.on('SIGTERM', () => {\n void handleShutdownSignal();\n });\n};\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ChildProcess } from 'child_process';
|
|
2
|
+
/**
|
|
3
|
+
* SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it
|
|
4
|
+
* has not gone within the grace window.
|
|
5
|
+
*
|
|
6
|
+
* Resolves rather than rejects on escalation: the caller's next move is the
|
|
7
|
+
* same either way (start the replacement), and a rejection would only give
|
|
8
|
+
* every call site a catch to write.
|
|
9
|
+
*/
|
|
10
|
+
export declare const killChildWithEscalation: (child: ChildProcess, graceMs?: number) => Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.killChildWithEscalation = void 0;
|
|
4
|
+
// Above the dev server's 5s worst-case shutdown budget, below docker's 10s
|
|
5
|
+
// default stop grace, so a container stop still exits cleanly rather than
|
|
6
|
+
// being SIGKILLed by docker mid-escalation.
|
|
7
|
+
const DEFAULT_KILL_GRACE_MS = 6000;
|
|
8
|
+
/**
|
|
9
|
+
* SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it
|
|
10
|
+
* has not gone within the grace window.
|
|
11
|
+
*
|
|
12
|
+
* Resolves rather than rejects on escalation: the caller's next move is the
|
|
13
|
+
* same either way (start the replacement), and a rejection would only give
|
|
14
|
+
* every call site a catch to write.
|
|
15
|
+
*/
|
|
16
|
+
const killChildWithEscalation = (child, graceMs = DEFAULT_KILL_GRACE_MS) => {
|
|
17
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
18
|
+
return Promise.resolve();
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
console.warn(`Dev server did not exit within ${graceMs}ms — sending SIGKILL.`);
|
|
23
|
+
child.kill('SIGKILL');
|
|
24
|
+
}, graceMs);
|
|
25
|
+
child.once('exit', () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve();
|
|
28
|
+
});
|
|
29
|
+
child.kill('SIGTERM');
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
exports.killChildWithEscalation = killChildWithEscalation;
|
|
33
|
+
//# sourceMappingURL=killChildWithEscalation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"killChildWithEscalation.js","sourceRoot":"","sources":["../../../src/lib/killChildWithEscalation.ts"],"names":[],"mappings":";;;AAWA,2EAA2E;AAC3E,0EAA0E;AAC1E,4CAA4C;AAC5C,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC;;;;;;;GAOG;AACI,MAAM,uBAAuB,GAAG,CAAC,KAAmB,EAAE,OAAO,GAAG,qBAAqB,EAAiB,EAAE;IAC7G,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QACzD,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAED,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,OAAO,CAAC,IAAI,CAAC,kCAAkC,OAAO,uBAAuB,CAAC,CAAC;YAC/E,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACxB,CAAC,EAAE,OAAO,CAAC,CAAC;QAEZ,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;YACtB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAlBW,QAAA,uBAAuB,2BAkBlC","sourcesContent":["// Stopping the dev server child, with a hard backstop.\n//\n// The dev server now runs an ordered shutdown on SIGTERM (drain in-flight\n// work, then checkpoint the stores) instead of dying instantly. That is the\n// point - it is what stops a save-triggered restart eating a queue message\n// mid-story - but it means a bare `child.kill()` followed by waiting for exit\n// can now wait forever if a teardown wedges. Without escalation this would\n// trade a data-loss bug for a hung-restart bug, and the next launch would find\n// its ports still held.\nimport { ChildProcess } from 'child_process';\n\n// Above the dev server's 5s worst-case shutdown budget, below docker's 10s\n// default stop grace, so a container stop still exits cleanly rather than\n// being SIGKILLed by docker mid-escalation.\nconst DEFAULT_KILL_GRACE_MS = 6000;\n\n/**\n * SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it\n * has not gone within the grace window.\n *\n * Resolves rather than rejects on escalation: the caller's next move is the\n * same either way (start the replacement), and a rejection would only give\n * every call site a catch to write.\n */\nexport const killChildWithEscalation = (child: ChildProcess, graceMs = DEFAULT_KILL_GRACE_MS): Promise<void> => {\n if (child.exitCode !== null || child.signalCode !== null) {\n return Promise.resolve();\n }\n\n return new Promise<void>((resolve) => {\n const timer = setTimeout(() => {\n console.warn(`Dev server did not exit within ${graceMs}ms — sending SIGKILL.`);\n child.kill('SIGKILL');\n }, graceMs);\n\n child.once('exit', () => {\n clearTimeout(timer);\n resolve();\n });\n\n child.kill('SIGTERM');\n });\n};\n"]}
|
|
@@ -40,17 +40,36 @@ exports.isQpqCliCommand = isQpqCliCommand;
|
|
|
40
40
|
// one (a real child can sit as a zombie answering `kill(pid, 0)` until this
|
|
41
41
|
// process's own event loop reaps it, which a tight sync loop would starve;
|
|
42
42
|
// doesn't apply to a process we didn't spawn).
|
|
43
|
-
|
|
43
|
+
//
|
|
44
|
+
// The default covers the dev server's graceful shutdown (drain in-flight work,
|
|
45
|
+
// then checkpoint the stores) with room to spare — it used to be 3s, which was
|
|
46
|
+
// fine when the server died instantly and is not now. Returns whether the pid
|
|
47
|
+
// actually went, because a caller about to bind these ports has to escalate
|
|
48
|
+
// rather than hope: giving up while the old server still holds 8080 is exactly
|
|
49
|
+
// the EADDRINUSE this file exists to prevent.
|
|
50
|
+
const waitForPidGone = (pid, timeoutMs = 8000) => {
|
|
44
51
|
const deadline = Date.now() + timeoutMs;
|
|
45
52
|
while (Date.now() < deadline) {
|
|
46
53
|
try {
|
|
47
54
|
process.kill(pid, 0);
|
|
48
55
|
}
|
|
49
56
|
catch (_a) {
|
|
50
|
-
return; // throws once the pid no longer exists
|
|
57
|
+
return true; // throws once the pid no longer exists
|
|
51
58
|
}
|
|
52
59
|
(0, child_process_1.execSync)('sleep 0.05');
|
|
53
60
|
}
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
// Last resort for a process that ignored, or wedged during, its graceful stop.
|
|
64
|
+
const killPidHard = (pid) => {
|
|
65
|
+
console.warn(`Process ${pid} did not exit gracefully — sending SIGKILL.`);
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 'SIGKILL');
|
|
68
|
+
}
|
|
69
|
+
catch (_a) {
|
|
70
|
+
return; // raced us and exited after all
|
|
71
|
+
}
|
|
72
|
+
waitForPidGone(pid, 2000);
|
|
54
73
|
};
|
|
55
74
|
// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,
|
|
56
75
|
// where /tmp is a symlink), so comparing against a caller-supplied root
|
|
@@ -128,7 +147,9 @@ const killOtherQpqDevProcesses = (root) => {
|
|
|
128
147
|
continue;
|
|
129
148
|
console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);
|
|
130
149
|
process.kill(pid, 'SIGINT');
|
|
131
|
-
waitForPidGone(pid)
|
|
150
|
+
if (!waitForPidGone(pid)) {
|
|
151
|
+
killPidHard(pid);
|
|
152
|
+
}
|
|
132
153
|
}
|
|
133
154
|
};
|
|
134
155
|
exports.killOtherQpqDevProcesses = killOtherQpqDevProcesses;
|
|
@@ -158,7 +179,9 @@ const killStaleListeners = (ports, isOurs) => {
|
|
|
158
179
|
if (isOurs(command)) {
|
|
159
180
|
console.log(`Killing stale dev server on port ${port} (pid ${pid})`);
|
|
160
181
|
process.kill(pid);
|
|
161
|
-
waitForPidGone(pid)
|
|
182
|
+
if (!waitForPidGone(pid)) {
|
|
183
|
+
killPidHard(pid);
|
|
184
|
+
}
|
|
162
185
|
}
|
|
163
186
|
else {
|
|
164
187
|
console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"killStaleListeners.js","sourceRoot":"","sources":["../../../src/lib/killStaleListeners.ts"],"names":[],"mappings":"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","sourcesContent":["// Pre-start sweep shared by the dev-server commands: a previous run whose\n// wrapper died without killing its listener (closed terminal, SIGKILL) leaves\n// an orphaned process holding a dev port, and the next launch dies with\n// EADDRINUSE. Before starting, kill any listener on the given ports whose\n// command line matches `isOurs`. Anything else on the ports is left alone\n// and reported — it's not ours.\nimport { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\n// Matches any invocation of the qpq CLI itself, regardless of how it was\n// launched: `node node_modules/.bin/qpq ...` (the npm-script shim, which has\n// no .js extension), a direct `node .../lib/commonjs/bin/qpq.js` call, a\n// global `qpq` shebang script, etc. Checks each whitespace-separated token's\n// basename (extension stripped) rather than a fixed substring like\n// 'bin/qpq.js', which only matches the last of those forms.\nexport const isQpqCliCommand = (command: string): boolean =>\n command\n .trim()\n .split(/\\s+/)\n .some((token) => {\n const base = path.basename(token);\n const withoutExt = base.endsWith('.js') ? base.slice(0, -3) : base;\n return withoutExt === 'qpq';\n });\n\n// SIGTERM only requests termination — the caller (about to bind this same\n// process's ports itself) needs it actually gone first. A stale dev server\n// can be mid-startup when killed (still resolving config, not yet bound to\n// every port it eventually binds) and keep running for a bit past the\n// signal, going on to bind a LATER port — waiting for just the one port that\n// matched isn't enough; wait for the whole pid to disappear so it can't bind\n// anything else afterward. Poll synchronously (this whole sweep runs before\n// anything async starts) via `kill(pid, 0)` — reliable and fast here because\n// these are unrelated, previously-orphaned processes, not children of this\n// one (a real child can sit as a zombie answering `kill(pid, 0)` until this\n// process's own event loop reaps it, which a tight sync loop would starve;\n// doesn't apply to a process we didn't spawn).\nconst waitForPidGone = (pid: number, timeoutMs = 3000): void => {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n try {\n process.kill(pid, 0);\n } catch {\n return; // throws once the pid no longer exists\n }\n execSync('sleep 0.05');\n }\n};\n\n// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,\n// where /tmp is a symlink), so comparing against a caller-supplied root\n// verbatim would false-negative whenever any component of the repo path is\n// itself a symlink (a symlinked home directory, an NFS mount, etc).\nconst getProcessCwd = (pid: number): string | null => {\n try {\n const line = execSync(`lsof -a -p ${pid} -d cwd -Fn`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n')\n .find((l) => l.startsWith('n'));\n return line ? fs.realpathSync(line.slice(1)) : null;\n } catch {\n return null; // pid gone, or we don't have permission to inspect it\n }\n};\n\n// The sweep below must never target this process's own wrappers: launched as\n// `npx qpq go:dev:api` (or via any shell whose -c string names qpq), the\n// ancestor chain (`sh -c \"npx ... qpq ...\"`, `npm exec qpq ...`) matches\n// isQpqCliCommand and shares our cwd, so excluding just process.pid isn't\n// enough — SIGINTing an ancestor tears down our own tree. Walk ppid to the\n// top and exclude the whole chain.\nconst getOwnAncestorPids = (): Set<number> => {\n const ancestors = new Set<number>();\n let pid = process.pid;\n while (pid > 1 && !ancestors.has(pid)) {\n ancestors.add(pid);\n try {\n pid = Number(\n execSync(`ps -o ppid= -p ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .trim(),\n );\n } catch {\n break;\n }\n }\n return ancestors;\n};\n\n// killStaleListeners only sees processes currently BOUND to one of the given\n// ports — it can't catch a still-running `qpq go:dev*` watcher whose spawned\n// dev-server child already died (a crash, or us killing just the child):\n// the watcher itself holds no port at that moment, so it's invisible to a\n// port scan, yet it's still alive and can spawn a fresh child — and bind a\n// port — moments later, racing whatever we're about to start. Close that gap\n// by finding any OTHER qpq CLI process rooted in the same repo checkout\n// (matched by cwd, not by port or command-line args, which vary by how it\n// was invoked) and killing it outright, regardless of what it currently has\n// bound. SIGINT (not SIGTERM) so goDevApiCommand/goDevWebCommand's own\n// handler runs and tears down its child cleanly rather than orphaning it.\nexport const killOtherQpqDevProcesses = (root: string): void => {\n const resolvedRoot = fs.realpathSync(root);\n const ownPids = getOwnAncestorPids();\n\n let lines: string[] = [];\n try {\n lines = execSync('ps -axo pid=,command=', { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n');\n } catch {\n return;\n }\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const spaceIndex = trimmed.indexOf(' ');\n if (spaceIndex === -1) continue;\n\n const pid = Number(trimmed.slice(0, spaceIndex));\n const command = trimmed.slice(spaceIndex + 1).trim();\n\n if (!pid || ownPids.has(pid) || !isQpqCliCommand(command)) continue;\n if (getProcessCwd(pid) !== resolvedRoot) continue;\n\n console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);\n process.kill(pid, 'SIGINT');\n waitForPidGone(pid);\n }\n};\n\nexport const killStaleListeners = (ports: number[], isOurs: (command: string) => boolean): void => {\n for (const port of ports) {\n let pids: number[] = [];\n try {\n pids = execSync(`lsof -t -iTCP:${port} -sTCP:LISTEN`, {\n stdio: ['ignore', 'pipe', 'ignore'],\n })\n .toString()\n .split('\\n')\n .map((line) => Number(line.trim()))\n .filter(Boolean);\n } catch {\n continue; // lsof exits non-zero when nothing is listening\n }\n\n for (const pid of pids) {\n let command = '';\n try {\n command = execSync(`ps -p ${pid} -o command=`).toString().trim();\n } catch {\n continue; // already gone\n }\n if (isOurs(command)) {\n console.log(`Killing stale dev server on port ${port} (pid ${pid})`);\n process.kill(pid);\n waitForPidGone(pid);\n } else {\n console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);\n }\n }\n }\n};\n"]}
|
|
1
|
+
{"version":3,"file":"killStaleListeners.js","sourceRoot":"","sources":["../../../src/lib/killStaleListeners.ts"],"names":[],"mappings":"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","sourcesContent":["// Pre-start sweep shared by the dev-server commands: a previous run whose\n// wrapper died without killing its listener (closed terminal, SIGKILL) leaves\n// an orphaned process holding a dev port, and the next launch dies with\n// EADDRINUSE. Before starting, kill any listener on the given ports whose\n// command line matches `isOurs`. Anything else on the ports is left alone\n// and reported — it's not ours.\nimport { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\n// Matches any invocation of the qpq CLI itself, regardless of how it was\n// launched: `node node_modules/.bin/qpq ...` (the npm-script shim, which has\n// no .js extension), a direct `node .../lib/commonjs/bin/qpq.js` call, a\n// global `qpq` shebang script, etc. Checks each whitespace-separated token's\n// basename (extension stripped) rather than a fixed substring like\n// 'bin/qpq.js', which only matches the last of those forms.\nexport const isQpqCliCommand = (command: string): boolean =>\n command\n .trim()\n .split(/\\s+/)\n .some((token) => {\n const base = path.basename(token);\n const withoutExt = base.endsWith('.js') ? base.slice(0, -3) : base;\n return withoutExt === 'qpq';\n });\n\n// SIGTERM only requests termination — the caller (about to bind this same\n// process's ports itself) needs it actually gone first. A stale dev server\n// can be mid-startup when killed (still resolving config, not yet bound to\n// every port it eventually binds) and keep running for a bit past the\n// signal, going on to bind a LATER port — waiting for just the one port that\n// matched isn't enough; wait for the whole pid to disappear so it can't bind\n// anything else afterward. Poll synchronously (this whole sweep runs before\n// anything async starts) via `kill(pid, 0)` — reliable and fast here because\n// these are unrelated, previously-orphaned processes, not children of this\n// one (a real child can sit as a zombie answering `kill(pid, 0)` until this\n// process's own event loop reaps it, which a tight sync loop would starve;\n// doesn't apply to a process we didn't spawn).\n//\n// The default covers the dev server's graceful shutdown (drain in-flight work,\n// then checkpoint the stores) with room to spare — it used to be 3s, which was\n// fine when the server died instantly and is not now. Returns whether the pid\n// actually went, because a caller about to bind these ports has to escalate\n// rather than hope: giving up while the old server still holds 8080 is exactly\n// the EADDRINUSE this file exists to prevent.\nconst waitForPidGone = (pid: number, timeoutMs = 8000): boolean => {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n try {\n process.kill(pid, 0);\n } catch {\n return true; // throws once the pid no longer exists\n }\n execSync('sleep 0.05');\n }\n\n return false;\n};\n\n// Last resort for a process that ignored, or wedged during, its graceful stop.\nconst killPidHard = (pid: number): void => {\n console.warn(`Process ${pid} did not exit gracefully — sending SIGKILL.`);\n try {\n process.kill(pid, 'SIGKILL');\n } catch {\n return; // raced us and exited after all\n }\n waitForPidGone(pid, 2000);\n};\n\n// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,\n// where /tmp is a symlink), so comparing against a caller-supplied root\n// verbatim would false-negative whenever any component of the repo path is\n// itself a symlink (a symlinked home directory, an NFS mount, etc).\nconst getProcessCwd = (pid: number): string | null => {\n try {\n const line = execSync(`lsof -a -p ${pid} -d cwd -Fn`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n')\n .find((l) => l.startsWith('n'));\n return line ? fs.realpathSync(line.slice(1)) : null;\n } catch {\n return null; // pid gone, or we don't have permission to inspect it\n }\n};\n\n// The sweep below must never target this process's own wrappers: launched as\n// `npx qpq go:dev:api` (or via any shell whose -c string names qpq), the\n// ancestor chain (`sh -c \"npx ... qpq ...\"`, `npm exec qpq ...`) matches\n// isQpqCliCommand and shares our cwd, so excluding just process.pid isn't\n// enough — SIGINTing an ancestor tears down our own tree. Walk ppid to the\n// top and exclude the whole chain.\nconst getOwnAncestorPids = (): Set<number> => {\n const ancestors = new Set<number>();\n let pid = process.pid;\n while (pid > 1 && !ancestors.has(pid)) {\n ancestors.add(pid);\n try {\n pid = Number(\n execSync(`ps -o ppid= -p ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .trim(),\n );\n } catch {\n break;\n }\n }\n return ancestors;\n};\n\n// killStaleListeners only sees processes currently BOUND to one of the given\n// ports — it can't catch a still-running `qpq go:dev*` watcher whose spawned\n// dev-server child already died (a crash, or us killing just the child):\n// the watcher itself holds no port at that moment, so it's invisible to a\n// port scan, yet it's still alive and can spawn a fresh child — and bind a\n// port — moments later, racing whatever we're about to start. Close that gap\n// by finding any OTHER qpq CLI process rooted in the same repo checkout\n// (matched by cwd, not by port or command-line args, which vary by how it\n// was invoked) and killing it outright, regardless of what it currently has\n// bound. SIGINT (not SIGTERM) so goDevApiCommand/goDevWebCommand's own\n// handler runs and tears down its child cleanly rather than orphaning it.\nexport const killOtherQpqDevProcesses = (root: string): void => {\n const resolvedRoot = fs.realpathSync(root);\n const ownPids = getOwnAncestorPids();\n\n let lines: string[] = [];\n try {\n lines = execSync('ps -axo pid=,command=', { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n');\n } catch {\n return;\n }\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const spaceIndex = trimmed.indexOf(' ');\n if (spaceIndex === -1) continue;\n\n const pid = Number(trimmed.slice(0, spaceIndex));\n const command = trimmed.slice(spaceIndex + 1).trim();\n\n if (!pid || ownPids.has(pid) || !isQpqCliCommand(command)) continue;\n if (getProcessCwd(pid) !== resolvedRoot) continue;\n\n console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);\n process.kill(pid, 'SIGINT');\n if (!waitForPidGone(pid)) {\n killPidHard(pid);\n }\n }\n};\n\nexport const killStaleListeners = (ports: number[], isOurs: (command: string) => boolean): void => {\n for (const port of ports) {\n let pids: number[] = [];\n try {\n pids = execSync(`lsof -t -iTCP:${port} -sTCP:LISTEN`, {\n stdio: ['ignore', 'pipe', 'ignore'],\n })\n .toString()\n .split('\\n')\n .map((line) => Number(line.trim()))\n .filter(Boolean);\n } catch {\n continue; // lsof exits non-zero when nothing is listening\n }\n\n for (const pid of pids) {\n let command = '';\n try {\n command = execSync(`ps -p ${pid} -o command=`).toString().trim();\n } catch {\n continue; // already gone\n }\n if (isOurs(command)) {\n console.log(`Killing stale dev server on port ${port} (pid ${pid})`);\n process.kill(pid);\n if (!waitForPidGone(pid)) {\n killPidHard(pid);\n }\n } else {\n console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);\n }\n }\n }\n};\n"]}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// generates the dev-server entry, builds the rspack config, then runs rspack
|
|
3
3
|
// in watch mode, restarting the server process on every successful rebuild.
|
|
4
4
|
//
|
|
5
|
+
// A restart is not instant: the server drains in-flight work and checkpoints
|
|
6
|
+
// its stores on the way out (typically tens of ms, 5s worst case), and this
|
|
7
|
+
// command waits for that before starting the replacement.
|
|
8
|
+
//
|
|
5
9
|
// The one thing NOT hot-reloaded is the qpq configs themselves: each service's
|
|
6
10
|
// infrastructure.ts is evaluated once at launch and baked into the
|
|
7
11
|
// `quidproquo-dynamic-loader` virtual module — config changes need a full
|
|
@@ -13,6 +17,7 @@ import { rspack } from '@rspack/core';
|
|
|
13
17
|
import { primeDeployEnvFromConfig } from '../lib/deployEnv';
|
|
14
18
|
import { writeDevServerEntry } from '../lib/devServerEntry';
|
|
15
19
|
import { getRoot } from '../lib/discovery';
|
|
20
|
+
import { killChildWithEscalation } from '../lib/killChildWithEscalation';
|
|
16
21
|
import { killOtherQpqDevProcesses, killStaleListeners } from '../lib/killStaleListeners';
|
|
17
22
|
import { resolveAppSelection } from '../lib/resolveAppSelection';
|
|
18
23
|
// 8080/8888 are set in the generated entry; 3001 is the quidproquo-dev-server
|
|
@@ -61,7 +66,10 @@ export const goDevApiCommand = async (argv) => {
|
|
|
61
66
|
restartQueued = false;
|
|
62
67
|
startServer();
|
|
63
68
|
});
|
|
64
|
-
|
|
69
|
+
// Not a bare kill: the server drains in-flight work before it exits, so it
|
|
70
|
+
// needs asking properly and then a hard backstop if it wedges. Waiting for
|
|
71
|
+
// the exit is what keeps the ports free for the replacement.
|
|
72
|
+
void killChildWithEscalation(child);
|
|
65
73
|
};
|
|
66
74
|
console.log('Bundling dev server (watch mode)...');
|
|
67
75
|
let lastHash;
|
|
@@ -85,10 +93,35 @@ export const goDevApiCommand = async (argv) => {
|
|
|
85
93
|
console.log(child ? 'Rebuilt. Restarting dev server...' : 'Dev server bundled. Starting...');
|
|
86
94
|
restartServer();
|
|
87
95
|
});
|
|
88
|
-
|
|
96
|
+
// Wait for the server to finish draining before tearing the watcher down and
|
|
97
|
+
// exiting: closing the compiler first would exit the parent while the child
|
|
98
|
+
// is still writing, which is the race this whole path exists to close.
|
|
99
|
+
//
|
|
100
|
+
// SIGTERM as well as SIGINT, and it is load-bearing rather than symmetry for
|
|
101
|
+
// its own sake. This process is a WRAPPER: the dev server that handles
|
|
102
|
+
// signals and drains its work is the child it spawned. Without a handler
|
|
103
|
+
// here, SIGTERM (what `docker stop` sends, and what any script stopping this
|
|
104
|
+
// programmatically sends) kills the wrapper outright on the default
|
|
105
|
+
// disposition, the child never gets asked to stop, and every guarantee the
|
|
106
|
+
// dev server's shutdown sequence makes is bypassed. go:dev:web already
|
|
107
|
+
// handles both.
|
|
108
|
+
let shuttingDown = false;
|
|
109
|
+
const handleShutdownSignal = async () => {
|
|
110
|
+
if (shuttingDown) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
shuttingDown = true;
|
|
89
114
|
restartQueued = true; // suppress the crash log / rebuild-restart on our own kill
|
|
90
|
-
child
|
|
115
|
+
if (child) {
|
|
116
|
+
await killChildWithEscalation(child);
|
|
117
|
+
}
|
|
91
118
|
compiler.close(() => process.exit(0));
|
|
119
|
+
};
|
|
120
|
+
process.on('SIGINT', () => {
|
|
121
|
+
void handleShutdownSignal();
|
|
122
|
+
});
|
|
123
|
+
process.on('SIGTERM', () => {
|
|
124
|
+
void handleShutdownSignal();
|
|
92
125
|
});
|
|
93
126
|
};
|
|
94
127
|
//# sourceMappingURL=goDevApi.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"goDevApi.js","sourceRoot":"","sources":["../../../src/commands/goDevApi.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"goDevApi.js","sourceRoot":"","sources":["../../../src/commands/goDevApi.ts"],"names":[],"mappings":"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","sourcesContent":["// `qpq go:dev:api` — boots the QPQ local dev server. Resolves the app,\n// generates the dev-server entry, builds the rspack config, then runs rspack\n// in watch mode, restarting the server process on every successful rebuild.\n//\n// A restart is not instant: the server drains in-flight work and checkpoints\n// its stores on the way out (typically tens of ms, 5s worst case), and this\n// command waits for that before starting the replacement.\n//\n// The one thing NOT hot-reloaded is the qpq configs themselves: each service's\n// infrastructure.ts is evaluated once at launch and baked into the\n// `quidproquo-dynamic-loader` virtual module — config changes need a full\n// `go:dev:api` restart.\nimport { getAppServiceQpqConfigs, getDevServerRspackConfig } from 'quidproquo-deploy-rspack';\n\nimport { ChildProcess, spawn } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport { rspack } from '@rspack/core';\n\nimport { primeDeployEnvFromConfig } from '../lib/deployEnv';\nimport { writeDevServerEntry } from '../lib/devServerEntry';\nimport { getRoot } from '../lib/discovery';\nimport { killChildWithEscalation } from '../lib/killChildWithEscalation';\nimport { killOtherQpqDevProcesses, killStaleListeners } from '../lib/killStaleListeners';\nimport { resolveAppSelection } from '../lib/resolveAppSelection';\n\n// 8080/8888 are set in the generated entry; 3001 is the quidproquo-dev-server\n// file-storage (secure URL) default port.\nconst DEV_SERVER_PORTS = [8080, 8888, 3001];\nconst DEV_SERVER_BUNDLE_PATH = path.join('dist', 'qpq', 'dev-server', 'main.js');\n\nexport const goDevApiCommand = async (argv: string[]): Promise<void> => {\n const root = getRoot();\n const appName = await resolveAppSelection({ argv, envVar: 'QPQ_DEV_APP' });\n process.env.QPQ_DEV_APP = appName;\n primeDeployEnvFromConfig(appName);\n console.log(`Dev server for app [${appName}]`);\n\n // Catches a lingering watcher from a previous run even if its spawned\n // child already exited (see killOtherQpqDevProcesses) — then the usual\n // port-based sweep for anything else still bound to our ports.\n killOtherQpqDevProcesses(root);\n killStaleListeners(DEV_SERVER_PORTS, (command) => command.includes(DEV_SERVER_BUNDLE_PATH));\n\n const qpqConfigs = getAppServiceQpqConfigs(root, appName);\n const entry = writeDevServerEntry(root, appName);\n const rspackConfig = getDevServerRspackConfig({ root, entry, qpqConfigs });\n const bundlePath = path.join(root, DEV_SERVER_BUNDLE_PATH);\n const env = { ...process.env, QPQ_DEV_APP: appName };\n\n // The long-lived server process (HTTP 8080 / WS 8888), restarted per rebuild.\n let child: ChildProcess | null = null;\n let restartQueued = false;\n\n const startServer = (): void => {\n child = spawn('node', [bundlePath], { stdio: 'inherit', env });\n child.on('exit', (code) => {\n child = null;\n if (!restartQueued) {\n // Crashed (or exited) on its own — leave it down; the next successful\n // rebuild (i.e. the next file save) brings it back up.\n console.log(`Dev server exited with code ${code}. Save a file to restart it.`);\n }\n });\n };\n\n const restartServer = (): void => {\n if (restartQueued) return;\n if (!child) {\n startServer();\n return;\n }\n restartQueued = true;\n child.once('exit', () => {\n restartQueued = false;\n startServer();\n });\n\n // Not a bare kill: the server drains in-flight work before it exits, so it\n // needs asking properly and then a hard backstop if it wedges. Waiting for\n // the exit is what keeps the ports free for the replacement.\n void killChildWithEscalation(child);\n };\n\n console.log('Bundling dev server (watch mode)...');\n let lastHash: string | null | undefined;\n const compiler = rspack(rspackConfig);\n compiler.watch({ aggregateTimeout: 200 }, (err, stats) => {\n if (err) {\n console.error(err);\n return;\n }\n if (stats?.hasErrors()) {\n console.error(stats.toString({ colors: true, chunks: false, modules: false }));\n console.log('Build failed — dev server not restarted. Fix the error and save again.');\n return;\n }\n // Skip no-op rebuilds (e.g. the virtual-module-triggered second compile at\n // startup): only restart when the output actually changed.\n if (stats?.hash === lastHash && child) {\n return;\n }\n lastHash = stats?.hash;\n console.log(child ? 'Rebuilt. Restarting dev server...' : 'Dev server bundled. Starting...');\n restartServer();\n });\n\n // Wait for the server to finish draining before tearing the watcher down and\n // exiting: closing the compiler first would exit the parent while the child\n // is still writing, which is the race this whole path exists to close.\n //\n // SIGTERM as well as SIGINT, and it is load-bearing rather than symmetry for\n // its own sake. This process is a WRAPPER: the dev server that handles\n // signals and drains its work is the child it spawned. Without a handler\n // here, SIGTERM (what `docker stop` sends, and what any script stopping this\n // programmatically sends) kills the wrapper outright on the default\n // disposition, the child never gets asked to stop, and every guarantee the\n // dev server's shutdown sequence makes is bypassed. go:dev:web already\n // handles both.\n let shuttingDown = false;\n\n const handleShutdownSignal = async (): Promise<void> => {\n if (shuttingDown) {\n return;\n }\n shuttingDown = true;\n\n restartQueued = true; // suppress the crash log / rebuild-restart on our own kill\n\n if (child) {\n await killChildWithEscalation(child);\n }\n\n compiler.close(() => process.exit(0));\n };\n\n process.on('SIGINT', () => {\n void handleShutdownSignal();\n });\n\n process.on('SIGTERM', () => {\n void handleShutdownSignal();\n });\n};\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ChildProcess } from 'child_process';
|
|
2
|
+
/**
|
|
3
|
+
* SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it
|
|
4
|
+
* has not gone within the grace window.
|
|
5
|
+
*
|
|
6
|
+
* Resolves rather than rejects on escalation: the caller's next move is the
|
|
7
|
+
* same either way (start the replacement), and a rejection would only give
|
|
8
|
+
* every call site a catch to write.
|
|
9
|
+
*/
|
|
10
|
+
export declare const killChildWithEscalation: (child: ChildProcess, graceMs?: number) => Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Above the dev server's 5s worst-case shutdown budget, below docker's 10s
|
|
2
|
+
// default stop grace, so a container stop still exits cleanly rather than
|
|
3
|
+
// being SIGKILLed by docker mid-escalation.
|
|
4
|
+
const DEFAULT_KILL_GRACE_MS = 6000;
|
|
5
|
+
/**
|
|
6
|
+
* SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it
|
|
7
|
+
* has not gone within the grace window.
|
|
8
|
+
*
|
|
9
|
+
* Resolves rather than rejects on escalation: the caller's next move is the
|
|
10
|
+
* same either way (start the replacement), and a rejection would only give
|
|
11
|
+
* every call site a catch to write.
|
|
12
|
+
*/
|
|
13
|
+
export const killChildWithEscalation = (child, graceMs = DEFAULT_KILL_GRACE_MS) => {
|
|
14
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
console.warn(`Dev server did not exit within ${graceMs}ms — sending SIGKILL.`);
|
|
20
|
+
child.kill('SIGKILL');
|
|
21
|
+
}, graceMs);
|
|
22
|
+
child.once('exit', () => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
resolve();
|
|
25
|
+
});
|
|
26
|
+
child.kill('SIGTERM');
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=killChildWithEscalation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"killChildWithEscalation.js","sourceRoot":"","sources":["../../../src/lib/killChildWithEscalation.ts"],"names":[],"mappings":"AAWA,2EAA2E;AAC3E,0EAA0E;AAC1E,4CAA4C;AAC5C,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,KAAmB,EAAE,OAAO,GAAG,qBAAqB,EAAiB,EAAE;IAC7G,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QACzD,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAED,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,OAAO,CAAC,IAAI,CAAC,kCAAkC,OAAO,uBAAuB,CAAC,CAAC;YAC/E,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACxB,CAAC,EAAE,OAAO,CAAC,CAAC;QAEZ,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;YACtB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC","sourcesContent":["// Stopping the dev server child, with a hard backstop.\n//\n// The dev server now runs an ordered shutdown on SIGTERM (drain in-flight\n// work, then checkpoint the stores) instead of dying instantly. That is the\n// point - it is what stops a save-triggered restart eating a queue message\n// mid-story - but it means a bare `child.kill()` followed by waiting for exit\n// can now wait forever if a teardown wedges. Without escalation this would\n// trade a data-loss bug for a hung-restart bug, and the next launch would find\n// its ports still held.\nimport { ChildProcess } from 'child_process';\n\n// Above the dev server's 5s worst-case shutdown budget, below docker's 10s\n// default stop grace, so a container stop still exits cleanly rather than\n// being SIGKILLed by docker mid-escalation.\nconst DEFAULT_KILL_GRACE_MS = 6000;\n\n/**\n * SIGTERM the child, resolve once it has actually exited, and SIGKILL it if it\n * has not gone within the grace window.\n *\n * Resolves rather than rejects on escalation: the caller's next move is the\n * same either way (start the replacement), and a rejection would only give\n * every call site a catch to write.\n */\nexport const killChildWithEscalation = (child: ChildProcess, graceMs = DEFAULT_KILL_GRACE_MS): Promise<void> => {\n if (child.exitCode !== null || child.signalCode !== null) {\n return Promise.resolve();\n }\n\n return new Promise<void>((resolve) => {\n const timer = setTimeout(() => {\n console.warn(`Dev server did not exit within ${graceMs}ms — sending SIGKILL.`);\n child.kill('SIGKILL');\n }, graceMs);\n\n child.once('exit', () => {\n clearTimeout(timer);\n resolve();\n });\n\n child.kill('SIGTERM');\n });\n};\n"]}
|
|
@@ -33,17 +33,36 @@ export const isQpqCliCommand = (command) => command
|
|
|
33
33
|
// one (a real child can sit as a zombie answering `kill(pid, 0)` until this
|
|
34
34
|
// process's own event loop reaps it, which a tight sync loop would starve;
|
|
35
35
|
// doesn't apply to a process we didn't spawn).
|
|
36
|
-
|
|
36
|
+
//
|
|
37
|
+
// The default covers the dev server's graceful shutdown (drain in-flight work,
|
|
38
|
+
// then checkpoint the stores) with room to spare — it used to be 3s, which was
|
|
39
|
+
// fine when the server died instantly and is not now. Returns whether the pid
|
|
40
|
+
// actually went, because a caller about to bind these ports has to escalate
|
|
41
|
+
// rather than hope: giving up while the old server still holds 8080 is exactly
|
|
42
|
+
// the EADDRINUSE this file exists to prevent.
|
|
43
|
+
const waitForPidGone = (pid, timeoutMs = 8000) => {
|
|
37
44
|
const deadline = Date.now() + timeoutMs;
|
|
38
45
|
while (Date.now() < deadline) {
|
|
39
46
|
try {
|
|
40
47
|
process.kill(pid, 0);
|
|
41
48
|
}
|
|
42
49
|
catch {
|
|
43
|
-
return; // throws once the pid no longer exists
|
|
50
|
+
return true; // throws once the pid no longer exists
|
|
44
51
|
}
|
|
45
52
|
execSync('sleep 0.05');
|
|
46
53
|
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
// Last resort for a process that ignored, or wedged during, its graceful stop.
|
|
57
|
+
const killPidHard = (pid) => {
|
|
58
|
+
console.warn(`Process ${pid} did not exit gracefully — sending SIGKILL.`);
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, 'SIGKILL');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return; // raced us and exited after all
|
|
64
|
+
}
|
|
65
|
+
waitForPidGone(pid, 2000);
|
|
47
66
|
};
|
|
48
67
|
// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,
|
|
49
68
|
// where /tmp is a symlink), so comparing against a caller-supplied root
|
|
@@ -121,7 +140,9 @@ export const killOtherQpqDevProcesses = (root) => {
|
|
|
121
140
|
continue;
|
|
122
141
|
console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);
|
|
123
142
|
process.kill(pid, 'SIGINT');
|
|
124
|
-
waitForPidGone(pid)
|
|
143
|
+
if (!waitForPidGone(pid)) {
|
|
144
|
+
killPidHard(pid);
|
|
145
|
+
}
|
|
125
146
|
}
|
|
126
147
|
};
|
|
127
148
|
export const killStaleListeners = (ports, isOurs) => {
|
|
@@ -150,7 +171,9 @@ export const killStaleListeners = (ports, isOurs) => {
|
|
|
150
171
|
if (isOurs(command)) {
|
|
151
172
|
console.log(`Killing stale dev server on port ${port} (pid ${pid})`);
|
|
152
173
|
process.kill(pid);
|
|
153
|
-
waitForPidGone(pid)
|
|
174
|
+
if (!waitForPidGone(pid)) {
|
|
175
|
+
killPidHard(pid);
|
|
176
|
+
}
|
|
154
177
|
}
|
|
155
178
|
else {
|
|
156
179
|
console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"killStaleListeners.js","sourceRoot":"","sources":["../../../src/lib/killStaleListeners.ts"],"names":[],"mappings":"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","sourcesContent":["// Pre-start sweep shared by the dev-server commands: a previous run whose\n// wrapper died without killing its listener (closed terminal, SIGKILL) leaves\n// an orphaned process holding a dev port, and the next launch dies with\n// EADDRINUSE. Before starting, kill any listener on the given ports whose\n// command line matches `isOurs`. Anything else on the ports is left alone\n// and reported — it's not ours.\nimport { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\n// Matches any invocation of the qpq CLI itself, regardless of how it was\n// launched: `node node_modules/.bin/qpq ...` (the npm-script shim, which has\n// no .js extension), a direct `node .../lib/commonjs/bin/qpq.js` call, a\n// global `qpq` shebang script, etc. Checks each whitespace-separated token's\n// basename (extension stripped) rather than a fixed substring like\n// 'bin/qpq.js', which only matches the last of those forms.\nexport const isQpqCliCommand = (command: string): boolean =>\n command\n .trim()\n .split(/\\s+/)\n .some((token) => {\n const base = path.basename(token);\n const withoutExt = base.endsWith('.js') ? base.slice(0, -3) : base;\n return withoutExt === 'qpq';\n });\n\n// SIGTERM only requests termination — the caller (about to bind this same\n// process's ports itself) needs it actually gone first. A stale dev server\n// can be mid-startup when killed (still resolving config, not yet bound to\n// every port it eventually binds) and keep running for a bit past the\n// signal, going on to bind a LATER port — waiting for just the one port that\n// matched isn't enough; wait for the whole pid to disappear so it can't bind\n// anything else afterward. Poll synchronously (this whole sweep runs before\n// anything async starts) via `kill(pid, 0)` — reliable and fast here because\n// these are unrelated, previously-orphaned processes, not children of this\n// one (a real child can sit as a zombie answering `kill(pid, 0)` until this\n// process's own event loop reaps it, which a tight sync loop would starve;\n// doesn't apply to a process we didn't spawn).\nconst waitForPidGone = (pid: number, timeoutMs = 3000): void => {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n try {\n process.kill(pid, 0);\n } catch {\n return; // throws once the pid no longer exists\n }\n execSync('sleep 0.05');\n }\n};\n\n// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,\n// where /tmp is a symlink), so comparing against a caller-supplied root\n// verbatim would false-negative whenever any component of the repo path is\n// itself a symlink (a symlinked home directory, an NFS mount, etc).\nconst getProcessCwd = (pid: number): string | null => {\n try {\n const line = execSync(`lsof -a -p ${pid} -d cwd -Fn`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n')\n .find((l) => l.startsWith('n'));\n return line ? fs.realpathSync(line.slice(1)) : null;\n } catch {\n return null; // pid gone, or we don't have permission to inspect it\n }\n};\n\n// The sweep below must never target this process's own wrappers: launched as\n// `npx qpq go:dev:api` (or via any shell whose -c string names qpq), the\n// ancestor chain (`sh -c \"npx ... qpq ...\"`, `npm exec qpq ...`) matches\n// isQpqCliCommand and shares our cwd, so excluding just process.pid isn't\n// enough — SIGINTing an ancestor tears down our own tree. Walk ppid to the\n// top and exclude the whole chain.\nconst getOwnAncestorPids = (): Set<number> => {\n const ancestors = new Set<number>();\n let pid = process.pid;\n while (pid > 1 && !ancestors.has(pid)) {\n ancestors.add(pid);\n try {\n pid = Number(\n execSync(`ps -o ppid= -p ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .trim(),\n );\n } catch {\n break;\n }\n }\n return ancestors;\n};\n\n// killStaleListeners only sees processes currently BOUND to one of the given\n// ports — it can't catch a still-running `qpq go:dev*` watcher whose spawned\n// dev-server child already died (a crash, or us killing just the child):\n// the watcher itself holds no port at that moment, so it's invisible to a\n// port scan, yet it's still alive and can spawn a fresh child — and bind a\n// port — moments later, racing whatever we're about to start. Close that gap\n// by finding any OTHER qpq CLI process rooted in the same repo checkout\n// (matched by cwd, not by port or command-line args, which vary by how it\n// was invoked) and killing it outright, regardless of what it currently has\n// bound. SIGINT (not SIGTERM) so goDevApiCommand/goDevWebCommand's own\n// handler runs and tears down its child cleanly rather than orphaning it.\nexport const killOtherQpqDevProcesses = (root: string): void => {\n const resolvedRoot = fs.realpathSync(root);\n const ownPids = getOwnAncestorPids();\n\n let lines: string[] = [];\n try {\n lines = execSync('ps -axo pid=,command=', { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n');\n } catch {\n return;\n }\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const spaceIndex = trimmed.indexOf(' ');\n if (spaceIndex === -1) continue;\n\n const pid = Number(trimmed.slice(0, spaceIndex));\n const command = trimmed.slice(spaceIndex + 1).trim();\n\n if (!pid || ownPids.has(pid) || !isQpqCliCommand(command)) continue;\n if (getProcessCwd(pid) !== resolvedRoot) continue;\n\n console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);\n process.kill(pid, 'SIGINT');\n waitForPidGone(pid);\n }\n};\n\nexport const killStaleListeners = (ports: number[], isOurs: (command: string) => boolean): void => {\n for (const port of ports) {\n let pids: number[] = [];\n try {\n pids = execSync(`lsof -t -iTCP:${port} -sTCP:LISTEN`, {\n stdio: ['ignore', 'pipe', 'ignore'],\n })\n .toString()\n .split('\\n')\n .map((line) => Number(line.trim()))\n .filter(Boolean);\n } catch {\n continue; // lsof exits non-zero when nothing is listening\n }\n\n for (const pid of pids) {\n let command = '';\n try {\n command = execSync(`ps -p ${pid} -o command=`).toString().trim();\n } catch {\n continue; // already gone\n }\n if (isOurs(command)) {\n console.log(`Killing stale dev server on port ${port} (pid ${pid})`);\n process.kill(pid);\n waitForPidGone(pid);\n } else {\n console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);\n }\n }\n }\n};\n"]}
|
|
1
|
+
{"version":3,"file":"killStaleListeners.js","sourceRoot":"","sources":["../../../src/lib/killStaleListeners.ts"],"names":[],"mappings":"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","sourcesContent":["// Pre-start sweep shared by the dev-server commands: a previous run whose\n// wrapper died without killing its listener (closed terminal, SIGKILL) leaves\n// an orphaned process holding a dev port, and the next launch dies with\n// EADDRINUSE. Before starting, kill any listener on the given ports whose\n// command line matches `isOurs`. Anything else on the ports is left alone\n// and reported — it's not ours.\nimport { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\n// Matches any invocation of the qpq CLI itself, regardless of how it was\n// launched: `node node_modules/.bin/qpq ...` (the npm-script shim, which has\n// no .js extension), a direct `node .../lib/commonjs/bin/qpq.js` call, a\n// global `qpq` shebang script, etc. Checks each whitespace-separated token's\n// basename (extension stripped) rather than a fixed substring like\n// 'bin/qpq.js', which only matches the last of those forms.\nexport const isQpqCliCommand = (command: string): boolean =>\n command\n .trim()\n .split(/\\s+/)\n .some((token) => {\n const base = path.basename(token);\n const withoutExt = base.endsWith('.js') ? base.slice(0, -3) : base;\n return withoutExt === 'qpq';\n });\n\n// SIGTERM only requests termination — the caller (about to bind this same\n// process's ports itself) needs it actually gone first. A stale dev server\n// can be mid-startup when killed (still resolving config, not yet bound to\n// every port it eventually binds) and keep running for a bit past the\n// signal, going on to bind a LATER port — waiting for just the one port that\n// matched isn't enough; wait for the whole pid to disappear so it can't bind\n// anything else afterward. Poll synchronously (this whole sweep runs before\n// anything async starts) via `kill(pid, 0)` — reliable and fast here because\n// these are unrelated, previously-orphaned processes, not children of this\n// one (a real child can sit as a zombie answering `kill(pid, 0)` until this\n// process's own event loop reaps it, which a tight sync loop would starve;\n// doesn't apply to a process we didn't spawn).\n//\n// The default covers the dev server's graceful shutdown (drain in-flight work,\n// then checkpoint the stores) with room to spare — it used to be 3s, which was\n// fine when the server died instantly and is not now. Returns whether the pid\n// actually went, because a caller about to bind these ports has to escalate\n// rather than hope: giving up while the old server still holds 8080 is exactly\n// the EADDRINUSE this file exists to prevent.\nconst waitForPidGone = (pid: number, timeoutMs = 8000): boolean => {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n try {\n process.kill(pid, 0);\n } catch {\n return true; // throws once the pid no longer exists\n }\n execSync('sleep 0.05');\n }\n\n return false;\n};\n\n// Last resort for a process that ignored, or wedged during, its graceful stop.\nconst killPidHard = (pid: number): void => {\n console.warn(`Process ${pid} did not exit gracefully — sending SIGKILL.`);\n try {\n process.kill(pid, 'SIGKILL');\n } catch {\n return; // raced us and exited after all\n }\n waitForPidGone(pid, 2000);\n};\n\n// realpath'd: lsof reports the canonical path (e.g. /private/tmp on macOS,\n// where /tmp is a symlink), so comparing against a caller-supplied root\n// verbatim would false-negative whenever any component of the repo path is\n// itself a symlink (a symlinked home directory, an NFS mount, etc).\nconst getProcessCwd = (pid: number): string | null => {\n try {\n const line = execSync(`lsof -a -p ${pid} -d cwd -Fn`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n')\n .find((l) => l.startsWith('n'));\n return line ? fs.realpathSync(line.slice(1)) : null;\n } catch {\n return null; // pid gone, or we don't have permission to inspect it\n }\n};\n\n// The sweep below must never target this process's own wrappers: launched as\n// `npx qpq go:dev:api` (or via any shell whose -c string names qpq), the\n// ancestor chain (`sh -c \"npx ... qpq ...\"`, `npm exec qpq ...`) matches\n// isQpqCliCommand and shares our cwd, so excluding just process.pid isn't\n// enough — SIGINTing an ancestor tears down our own tree. Walk ppid to the\n// top and exclude the whole chain.\nconst getOwnAncestorPids = (): Set<number> => {\n const ancestors = new Set<number>();\n let pid = process.pid;\n while (pid > 1 && !ancestors.has(pid)) {\n ancestors.add(pid);\n try {\n pid = Number(\n execSync(`ps -o ppid= -p ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .trim(),\n );\n } catch {\n break;\n }\n }\n return ancestors;\n};\n\n// killStaleListeners only sees processes currently BOUND to one of the given\n// ports — it can't catch a still-running `qpq go:dev*` watcher whose spawned\n// dev-server child already died (a crash, or us killing just the child):\n// the watcher itself holds no port at that moment, so it's invisible to a\n// port scan, yet it's still alive and can spawn a fresh child — and bind a\n// port — moments later, racing whatever we're about to start. Close that gap\n// by finding any OTHER qpq CLI process rooted in the same repo checkout\n// (matched by cwd, not by port or command-line args, which vary by how it\n// was invoked) and killing it outright, regardless of what it currently has\n// bound. SIGINT (not SIGTERM) so goDevApiCommand/goDevWebCommand's own\n// handler runs and tears down its child cleanly rather than orphaning it.\nexport const killOtherQpqDevProcesses = (root: string): void => {\n const resolvedRoot = fs.realpathSync(root);\n const ownPids = getOwnAncestorPids();\n\n let lines: string[] = [];\n try {\n lines = execSync('ps -axo pid=,command=', { stdio: ['ignore', 'pipe', 'ignore'] })\n .toString()\n .split('\\n');\n } catch {\n return;\n }\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n\n const spaceIndex = trimmed.indexOf(' ');\n if (spaceIndex === -1) continue;\n\n const pid = Number(trimmed.slice(0, spaceIndex));\n const command = trimmed.slice(spaceIndex + 1).trim();\n\n if (!pid || ownPids.has(pid) || !isQpqCliCommand(command)) continue;\n if (getProcessCwd(pid) !== resolvedRoot) continue;\n\n console.log(`Killing other qpq dev process for this repo (pid ${pid}): ${command}`);\n process.kill(pid, 'SIGINT');\n if (!waitForPidGone(pid)) {\n killPidHard(pid);\n }\n }\n};\n\nexport const killStaleListeners = (ports: number[], isOurs: (command: string) => boolean): void => {\n for (const port of ports) {\n let pids: number[] = [];\n try {\n pids = execSync(`lsof -t -iTCP:${port} -sTCP:LISTEN`, {\n stdio: ['ignore', 'pipe', 'ignore'],\n })\n .toString()\n .split('\\n')\n .map((line) => Number(line.trim()))\n .filter(Boolean);\n } catch {\n continue; // lsof exits non-zero when nothing is listening\n }\n\n for (const pid of pids) {\n let command = '';\n try {\n command = execSync(`ps -p ${pid} -o command=`).toString().trim();\n } catch {\n continue; // already gone\n }\n if (isOurs(command)) {\n console.log(`Killing stale dev server on port ${port} (pid ${pid})`);\n process.kill(pid);\n if (!waitForPidGone(pid)) {\n killPidHard(pid);\n }\n } else {\n console.warn(`Port ${port} is in use by an unrelated process (pid ${pid}: ${command}) — not killing it.`);\n }\n }\n }\n};\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quidproquo-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "The qpq CLI — build, dev and deploy orchestration for QPQ apps",
|
|
5
5
|
"main": "./lib/commonjs/index.js",
|
|
6
6
|
"module": "./lib/esm/index.js",
|
|
@@ -44,21 +44,21 @@
|
|
|
44
44
|
"@rspack/dev-server": "^2.1.0",
|
|
45
45
|
"aws-cdk": "^2.1132.1",
|
|
46
46
|
"madge": "^8.0.0",
|
|
47
|
-
"quidproquo": "0.1.
|
|
48
|
-
"quidproquo-actionprocessor-awslambda": "0.1.
|
|
49
|
-
"quidproquo-config-aws": "0.1.
|
|
50
|
-
"quidproquo-core": "0.1.
|
|
51
|
-
"quidproquo-deploy-awscdk": "0.1.
|
|
52
|
-
"quidproquo-deploy-rspack": "0.1.
|
|
53
|
-
"quidproquo-dev-server": "0.1.
|
|
54
|
-
"quidproquo-webserver": "0.1.
|
|
47
|
+
"quidproquo": "0.1.19",
|
|
48
|
+
"quidproquo-actionprocessor-awslambda": "0.1.19",
|
|
49
|
+
"quidproquo-config-aws": "0.1.19",
|
|
50
|
+
"quidproquo-core": "0.1.19",
|
|
51
|
+
"quidproquo-deploy-awscdk": "0.1.19",
|
|
52
|
+
"quidproquo-deploy-rspack": "0.1.19",
|
|
53
|
+
"quidproquo-dev-server": "0.1.19",
|
|
54
|
+
"quidproquo-webserver": "0.1.19",
|
|
55
55
|
"ts-node": "^10.9.1",
|
|
56
56
|
"typescript": "^5.8.2"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@rspack/core": "^2.1.2",
|
|
60
60
|
"@types/node": "^22.13.13",
|
|
61
|
-
"quidproquo-tsconfig": "0.1.
|
|
61
|
+
"quidproquo-tsconfig": "0.1.19"
|
|
62
62
|
},
|
|
63
63
|
"bin": {
|
|
64
64
|
"qpq": "./lib/commonjs/bin/qpq.js"
|