a5bf653a47
On Windows, Bun doesn't properly release socket handles when the worker process exits, causing "zombie ports" that remain bound even after all processes are dead. This required a system reboot to clear. Solution: Introduce a wrapper process (worker-wrapper.cjs) that: - Spawns the actual worker as a child with IPC channel - On restart/shutdown, uses `taskkill /T /F` to kill the entire process tree - Exits itself, allowing hooks to start fresh The wrapper has no sockets, so Bun's socket cleanup bug doesn't affect it. When the wrapper kills the inner worker tree and exits, the port is properly released and can be immediately reused. Key changes: - New worker-wrapper.ts for Windows process lifecycle management - ProcessManager starts wrapper on Windows, worker directly on Unix - Worker sends IPC messages to wrapper for restart/shutdown - Health endpoint now includes debug info (build ID, managed status, hasIpc) Tested: Restart API now properly releases port and new worker binds to same port. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
6 lines
12 KiB
JavaScript
Executable File
6 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
import{existsSync as M,readFileSync as et,writeFileSync as rt,unlinkSync as nt,mkdirSync as v}from"fs";import{createWriteStream as ot}from"fs";import{join as h}from"path";import{spawn as st,spawnSync as it}from"child_process";import{homedir as at}from"os";import{join as c,dirname as J,basename as It}from"path";import{homedir as q}from"os";import{fileURLToPath as Q}from"url";import{readFileSync as V,writeFileSync as j,existsSync as G}from"fs";import{join as Y}from"path";import{homedir as X}from"os";var K=["bugfix","feature","refactor","discovery","decision","change"],B=["how-it-works","why-it-exists","what-changed","problem-solution","gotcha","pattern","trade-off"];var R=K.join(","),I=B.join(",");var O=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(O||{}),A=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=l.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=O[t]??1}return this.level}correlationId(t,e){return`obs-${t}-${e}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
|
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let e=Object.keys(t);return e.length===0?"{}":e.length<=3?JSON.stringify(t):`{${e.length} keys: ${e.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,e){if(!e)return t;try{let r=typeof e=="string"?JSON.parse(e):e;if(t==="Bash"&&r.command){let n=r.command.length>50?r.command.substring(0,50)+"...":r.command;return`${t}(${n})`}if(t==="Read"&&r.file_path){let n=r.file_path.split("/").pop()||r.file_path;return`${t}(${n})`}if(t==="Edit"&&r.file_path){let n=r.file_path.split("/").pop()||r.file_path;return`${t}(${n})`}if(t==="Write"&&r.file_path){let n=r.file_path.split("/").pop()||r.file_path;return`${t}(${n})`}return t}catch{return t}}formatTimestamp(t){let e=t.getFullYear(),r=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),i=String(t.getMinutes()).padStart(2,"0"),a=String(t.getSeconds()).padStart(2,"0"),p=String(t.getMilliseconds()).padStart(3,"0");return`${e}-${r}-${n} ${o}:${i}:${a}.${p}`}log(t,e,r,n,o){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),a=O[t].padEnd(5),p=e.padEnd(6),g="";n?.correlationId?g=`[${n.correlationId}] `:n?.sessionId&&(g=`[session-${n.sessionId}] `);let m="";o!=null&&(this.getLevel()===0&&typeof o=="object"?m=`
|
|
`+JSON.stringify(o,null,2):m=" "+this.formatData(o));let E="";if(n){let{sessionId:_t,sdkSessionId:St,correlationId:dt,...P}=n;Object.keys(P).length>0&&(E=` {${Object.entries(P).map(([F,H])=>`${F}=${H}`).join(", ")}}`)}let _=`[${i}] [${a}] [${p}] ${g}${r}${E}${m}`;t===3?console.error(_):console.log(_)}debug(t,e,r,n){this.log(0,t,e,r,n)}info(t,e,r,n){this.log(1,t,e,r,n)}warn(t,e,r,n){this.log(2,t,e,r,n)}error(t,e,r,n){this.log(3,t,e,r,n)}dataIn(t,e,r,n){this.info(t,`\u2192 ${e}`,r,n)}dataOut(t,e,r,n){this.info(t,`\u2190 ${e}`,r,n)}success(t,e,r,n){this.info(t,`\u2713 ${e}`,r,n)}failure(t,e,r,n){this.error(t,`\u2717 ${e}`,r,n)}timing(t,e,r,n){this.info(t,`\u23F1 ${e}`,n,{duration:`${r}ms`})}happyPathError(t,e,r,n,o=""){let g=((new Error().stack||"").split(`
|
|
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),m=g?`${g[1].split("/").pop()}:${g[2]}`:"unknown",E={...r,location:m};return this.warn(t,`[HAPPY-PATH] ${e}`,E,n),o}},d=new A;var l=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_DATA_DIR:Y(X(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:R,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:I,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let e=this.get(t);return parseInt(e,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){if(!G(t))return this.getAllDefaults();let e=V(t,"utf-8"),r=JSON.parse(e),n=r;if(r.env&&typeof r.env=="object"){n=r.env;try{j(t,JSON.stringify(n,null,2),"utf-8"),d.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){d.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let o={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))n[i]!==void 0&&(o[i]=n[i]);return o}};function z(){return typeof __dirname<"u"?__dirname:J(Q(import.meta.url))}var Ut=z(),u=l.get("CLAUDE_MEM_DATA_DIR"),C=process.env.CLAUDE_CONFIG_DIR||c(q(),".claude"),Nt=c(u,"archives"),xt=c(u,"logs"),$t=c(u,"trash"),Wt=c(u,"backups"),Ft=c(u,"settings.json"),Ht=c(u,"claude-mem.db"),Kt=c(u,"vector-db"),Bt=c(C,"settings.json"),Vt=c(C,"commands"),jt=c(C,"CLAUDE.md");import{spawnSync as Z}from"child_process";import{existsSync as tt}from"fs";import{join as b}from"path";import{homedir as y}from"os";function D(){let s=process.platform==="win32";try{if(Z("bun",["--version"],{encoding:"utf-8",stdio:["pipe","pipe","pipe"],shell:!1}).status===0)return"bun"}catch{}let t=s?[b(y(),".bun","bin","bun.exe")]:[b(y(),".bun","bin","bun"),"/usr/local/bin/bun","/opt/homebrew/bin/bun","/home/linuxbrew/.linuxbrew/bin/bun"];for(let e of t)if(tt(e))return e;return null}function k(){return D()!==null}var T=h(u,"worker.pid"),U=h(u,"logs"),L=h(at(),".claude","plugins","marketplaces","thedotmack"),ct=5e3,ut=1e4,lt=200,pt=1e3,gt=100,f=class{static async start(t){if(isNaN(t)||t<1024||t>65535)return{success:!1,error:`Invalid port ${t}. Must be between 1024 and 65535`};if(await this.isRunning())return{success:!0,pid:this.getPidInfo()?.pid};v(U,{recursive:!0});let e=process.platform==="win32"?"worker-wrapper.cjs":"worker-service.cjs",r=h(L,"plugin","scripts",e);if(!M(r))return{success:!1,error:`Worker script not found at ${r}`};let n=this.getLogFilePath();return this.startWithBun(r,n,t)}static isBunAvailable(){return k()}static escapePowerShellString(t){return t.replace(/'/g,"''")}static async startWithBun(t,e,r){let n=D();if(!n)return{success:!1,error:"Bun is required but not found in PATH or common installation paths. Install from https://bun.sh"};try{if(process.platform==="win32"){let i=this.escapePowerShellString(n),a=this.escapePowerShellString(t),p=this.escapePowerShellString(L),m=`${`$env:CLAUDE_MEM_WORKER_PORT='${r}'`}; Start-Process -FilePath '${i}' -ArgumentList '${a}' -WorkingDirectory '${p}' -WindowStyle Hidden -PassThru | Select-Object -ExpandProperty Id`,E=it("powershell",["-Command",m],{stdio:"pipe",timeout:1e4,windowsHide:!0});if(E.status!==0)return{success:!1,error:`PowerShell spawn failed: ${E.stderr?.toString()||"unknown error"}`};let _=parseInt(E.stdout.toString().trim(),10);return isNaN(_)?{success:!1,error:"Failed to get PID from PowerShell"}:(this.writePidFile({pid:_,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(_,r))}else{let i=st(n,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:L}),a=ot(e,{flags:"a"});return i.stdout?.pipe(a),i.stderr?.pipe(a),i.unref(),i.pid?(this.writePidFile({pid:i.pid,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(i.pid,r)):{success:!1,error:"Failed to get PID from spawned process"}}}catch(o){return{success:!1,error:o instanceof Error?o.message:String(o)}}}static async stop(t=ct){let e=this.getPidInfo();if(!e)return!0;try{if(process.platform==="win32"){let{execSync:r}=await import("child_process");try{r(`taskkill /PID ${e.pid} /T /F`,{timeout:1e4,stdio:"ignore"})}catch{}}else process.kill(e.pid,"SIGTERM"),await this.waitForExit(e.pid,t)}catch{try{process.kill(e.pid,"SIGKILL")}catch{}}return this.removePidFile(),!0}static async restart(t){return await this.stop(),this.start(t)}static async status(){let t=this.getPidInfo();if(!t)return{running:!1};let e=this.isProcessAlive(t.pid);return{running:e,pid:e?t.pid:void 0,port:e?t.port:void 0,uptime:e?this.formatUptime(t.startedAt):void 0}}static async isRunning(){let t=this.getPidInfo();if(!t)return!1;let e=this.isProcessAlive(t.pid);return e||this.removePidFile(),e}static getPidInfo(){try{if(!M(T))return null;let t=et(T,"utf-8"),e=JSON.parse(t);return typeof e.pid!="number"||typeof e.port!="number"?null:e}catch{return null}}static writePidFile(t){v(u,{recursive:!0}),rt(T,JSON.stringify(t,null,2))}static removePidFile(){try{M(T)&&nt(T)}catch{}}static isProcessAlive(t){try{return process.kill(t,0),!0}catch{return!1}}static async waitForHealth(t,e,r=ut){let n=Date.now();for(;Date.now()-n<r;){if(!this.isProcessAlive(t))return{success:!1,error:"Process died during startup"};try{if((await fetch(`http://127.0.0.1:${e}/health`,{signal:AbortSignal.timeout(pt)})).ok)return{success:!0,pid:t}}catch{}await new Promise(o=>setTimeout(o,lt))}return{success:!1,error:"Health check timed out"}}static async waitForExit(t,e){let r=Date.now();for(;Date.now()-r<e;){if(!this.isProcessAlive(t))return;await new Promise(n=>setTimeout(n,gt))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return h(U,`worker-${t}.log`)}static formatUptime(t){let e=new Date(t).getTime(),n=Date.now()-e,o=Math.floor(n/1e3),i=Math.floor(o/60),a=Math.floor(i/60),p=Math.floor(a/24);return p>0?`${p}d ${a%24}h`:a>0?`${a}h ${i%60}m`:i>0?`${i}m ${o%60}s`:`${o}s`}};import x from"path";import{homedir as Et}from"os";var w={DEFAULT:5e3,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function N(s){return process.platform==="win32"?Math.round(s*w.WINDOWS_MULTIPLIER):s}var _e=x.join(Et(),".claude","plugins","marketplaces","thedotmack"),Se=N(w.HEALTH_CHECK),S=null;function $(){if(S!==null)return S;try{let s=x.join(l.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=l.loadFromFile(s);return S=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),S}catch(s){return d.debug("SYSTEM","Failed to load port from settings, using default",{error:s}),S=parseInt(l.get("CLAUDE_MEM_WORKER_PORT"),10),S}}var ft=process.argv[2],W=$();async function mt(){switch(ft){case"start":{let s=await f.start(W);if(s.success){console.log(`Worker started (PID: ${s.pid})`);let t=new Date().toISOString().slice(0,10);console.log(`Logs: ~/.claude-mem/logs/worker-${t}.log`),process.exit(0)}else console.error(`Failed to start: ${s.error}`),process.exit(1);break}case"stop":await f.stop(),console.log("Worker stopped"),process.exit(0);case"restart":{let s=await f.restart(W);s.success?(console.log(`Worker restarted (PID: ${s.pid})`),process.exit(0)):(console.error(`Failed to restart: ${s.error}`),process.exit(1));break}case"status":{let s=await f.status();s.running?(console.log("Worker is running"),console.log(` PID: ${s.pid}`),console.log(` Port: ${s.port}`),console.log(` Uptime: ${s.uptime}`)):console.log("Worker is not running"),process.exit(0)}default:console.log("Usage: worker-cli.js <start|stop|restart|status>"),process.exit(1)}}mt().catch(s=>{console.error(s),process.exit(1)});
|