@@ -42,7 +42,7 @@ interface PidFileData {
4242 * Parse a PID file. Handles both the new JSON format and the legacy plain-text
4343 * format (just a number). Returns null on parse failure or missing file.
4444 */
45- function readPidFile ( pidFile : string ) : PidFileData | null {
45+ export function readPidFile ( pidFile : string ) : PidFileData | null {
4646 let raw : string ;
4747 try {
4848 raw = fs . readFileSync ( pidFile , 'utf8' ) . trim ( ) ;
@@ -81,7 +81,7 @@ function readPidFile(pidFile: string): PidFileData | null {
8181 * Returns false for dead PIDs and for PIDs that are alive but clearly not ours
8282 * (i.e. PID reuse case).
8383 */
84- function isDaemonProcessAlive ( pid : number ) : boolean {
84+ export function isDaemonProcessAlive ( pid : number ) : boolean {
8585 if ( ! isProcessAlive ( pid ) ) return false ;
8686 // Best-effort PID reuse detection via /proc/<pid>/comm (Linux only).
8787 try {
@@ -442,9 +442,13 @@ let verboseMode = false;
442442function log ( message : string ) : void {
443443 const line = message . startsWith ( '[' ) ? message : `[${ new Date ( ) . toISOString ( ) } ] ${ message } ` ;
444444 if ( logStream ) {
445+ // In forked mode the WriteStream IS the log destination — avoid
446+ // double-writing via the (now-redirected) console.log which also
447+ // forwards to the same stream.
445448 logStream . write ( line + '\n' ) ;
449+ } else {
450+ console . log ( line ) ;
446451 }
447- console . log ( line ) ;
448452}
449453
450454// =============================================================================
@@ -569,8 +573,23 @@ export async function runDaemon(
569573 throw e ;
570574 }
571575
572- // Disconnect from parent
573- if ( process . disconnect ) process . disconnect ( ) ;
576+ // Disconnect from parent's IPC channel if one exists. The parent's
577+ // detachDaemon call passes 'ipc' in stdio (Fix issue #19) so the channel
578+ // is normally open here; the `process.connected` guard handles the
579+ // edge case of running with a non-IPC stdio (test harnesses, manual
580+ // invocation of `daemon start --_forked`). Calling `process.disconnect()`
581+ // without a live channel throws "IPC channel is not open", which under
582+ // `stdio: 'ignore'` would crash the child silently with no log trail.
583+ //
584+ // The try/catch handles a residual race: the parent's child.disconnect()
585+ // closes the channel at the OS layer, but the JS 'disconnect' event
586+ // (which flips process.connected to false) is async — there's a microtask
587+ // window where process.connected reads true while the underlying channel
588+ // is already torn down, in which case disconnect() throws. Swallowing
589+ // here is correct: the goal state (channel closed) already holds.
590+ if ( process . connected && process . disconnect ) {
591+ try { process . disconnect ( ) ; } catch { /* already torn down by parent */ }
592+ }
574593
575594 // Restore on exit for cleanup logging
576595 process . on ( 'exit' , ( ) => {
@@ -697,14 +716,21 @@ export async function runDaemon(
697716// =============================================================================
698717
699718function detachDaemon ( args : string [ ] , flags : DaemonFlags ) : void {
700- // Build the resolved config just to get the PID file path
719+ // Resolve config once so we have BOTH the PID file path (for the advisory
720+ // already-running check below) AND the log file path (so we can open it in
721+ // the parent and inherit the fd into the child — see fork() call below).
701722 let pidFile : string ;
723+ let logFile : string ;
702724 try {
703725 const rawConfig = buildConfigFromFlags ( flags ) ;
704726 const config = resolveConfig ( rawConfig ) ;
705727 pidFile = config . pidFile ;
728+ logFile = config . logFile ;
706729 } catch {
707730 pidFile = getDefaultPidFile ( ) ;
731+ // Match the default emitted by the success message below so the operator
732+ // sees a consistent path. resolveConfig() would normally produce this.
733+ logFile = '.sphere-cli/daemon.log' ;
708734 }
709735
710736 // Check if already running. Note: this check is advisory only — the forked
@@ -723,22 +749,43 @@ function detachDaemon(args: string[], flags: DaemonFlags): void {
723749 // Build child args: replace --detach with --_forked, keep everything else
724750 const childArgs = [ 'daemon' , 'start' , '--_forked' , ...args . filter ( a => a !== '--detach' ) ] ;
725751
726- // Fork the child process
752+ // Fix issue #19: Open the log file in the parent and pass its fd as the
753+ // child's stdout and stderr. The previous `stdio: 'ignore'` discarded both
754+ // streams, so any failure between fork() and the child's first WriteStream
755+ // flush — including the silent crash from `process.disconnect()` throwing
756+ // on a missing IPC channel — was invisible: no log, no PID-file cleanup,
757+ // just a stale pid. With the fd inherited at OS level, any thrown error,
758+ // uncaught exception, or stderr emission lands in the log file before any
759+ // Node-level streaming machinery is required.
760+ //
761+ // The 'ipc' entry is required by child_process.fork() (Node throws
762+ // "Forked processes must have an IPC channel" without it). The parent
763+ // does not send messages over the channel — it exists solely to satisfy
764+ // fork's contract. The child's runDaemon() calls process.disconnect()
765+ // (guarded on process.connected) to release the channel from the child's
766+ // event loop after PID-file write.
767+ ensureDir ( logFile ) ;
768+ const logFd = fs . openSync ( logFile , 'a' ) ;
769+ fs . writeSync (
770+ logFd ,
771+ `[${ new Date ( ) . toISOString ( ) } ] sphere daemon: forking child (parent PID ${ process . pid } )\n` ,
772+ ) ;
727773 const child = fork ( process . argv [ 1 ] , childArgs , {
728774 detached : true ,
729- stdio : 'ignore' ,
775+ stdio : [ 'ignore' , logFd , logFd , 'ipc' ] ,
730776 } ) ;
777+ // Child has inherited its own copy of the fd; close the parent's reference.
778+ fs . closeSync ( logFd ) ;
731779
780+ // Don't keep the parent alive waiting for the child. We also disconnect
781+ // the parent's end of the IPC channel so process.exit(0) below doesn't
782+ // need to forcibly tear down a live handle.
732783 child . unref ( ) ;
784+ if ( child . connected ) child . disconnect ( ) ;
733785
734786 console . log ( `Daemon started in background (PID ${ child . pid } )` ) ;
735787 console . log ( `PID file: ${ pidFile } ` ) ;
736-
737- if ( flags . logFile ) {
738- console . log ( `Log file: ${ flags . logFile } ` ) ;
739- } else {
740- console . log ( 'Log file: .sphere-cli/daemon.log' ) ;
741- }
788+ console . log ( `Log file: ${ logFile } ` ) ;
742789
743790 process . exit ( 0 ) ;
744791}
0 commit comments