Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/relay-ending-signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@memnox/core': patch
'@memnox/proxy': patch
'@memnox/interceptors': patch
'memnox': patch
---

`memnox-shell` and the command interceptors now pass SIGTERM and SIGHUP on to the command they started, so an agent that stops a timed out call stops the real shell, `git` or `npm` with it. The wrapper then exits 143 for SIGTERM, as a shell would.
33 changes: 33 additions & 0 deletions packages/interceptors/src/child-signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** Signals sent to the wrapper alone, which the command it started never sees unless relayed. */
const ENDING_SIGNALS = ['SIGTERM', 'SIGHUP'] as const;

/** The one thing relaying needs from a child, so a test can hand in a real one. */
interface Killable {
kill(signal: NodeJS.Signals): boolean;
}

/** Where the signals arrive, which is the process itself outside a test. */
interface SignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
off(signal: NodeJS.Signals, listener: () => void): unknown;
}

/**
* Passes an ending signal on to the child, so the command stops with its
* wrapper rather than outliving it; returns the detach to call on exit.
*/
export function relayEndingSignals(
child: Killable,
source: SignalSource = process,
): () => void {
const listeners = ENDING_SIGNALS.map((signal) => {
const listener = (): void => {
child.kill(signal);
};
source.on(signal, listener);
return { signal, listener };
});
return () => {
for (const { signal, listener } of listeners) source.off(signal, listener);
};
}
3 changes: 3 additions & 0 deletions packages/interceptors/src/interceptor-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { observeSession, pauseHolding, pauseMessage } from './breaker-seam';
import { BrowserSeam } from './browser-seam';
import { checkpointBeforeCommand } from './checkpoint-seam';
import { relayEndingSignals } from './child-signals';
import { reportToDaemon } from './daemon-client';
import { readHookConfig } from './hook-config';
import { loadHookGate } from './hook-gate-loader';
Expand Down Expand Up @@ -321,12 +322,14 @@ function hand(command: Command, home: string): Promise<number> {
stdio: 'inherit',
env: { ...process.env, PATH: path },
});
const detach = relayEndingSignals(child);
child.on('error', (err: unknown) => {
process.stderr.write(`memnox: could not run ${command.binary}: ${String(err)}\n`);
resolve(exitCodeForSpawnError(err));
});
child.on('exit', (code, signal) => {
for (const each of FORWARDED) process.off(each, ignore);
detach();
resolve(exitCodeForChild(code, signal));
});
});
Expand Down
3 changes: 3 additions & 0 deletions packages/interceptors/src/shell-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@memnox/core';

import { checkpointBeforeLine } from './checkpoint-seam';
import { relayEndingSignals } from './child-signals';
import { record } from './record';
import {
buildAuthorizer,
Expand Down Expand Up @@ -66,7 +67,9 @@ function commandOf(invocation: ShellInvocation): string[] {
function run(executable: string, args: readonly string[]): Promise<number> {
return new Promise((resolve) => {
const child = spawn(executable, args, { stdio: 'inherit' });
const detach = relayEndingSignals(child);
child.on('exit', (code, signal) => {
detach();
resolve(exitCodeForChild(code, signal));
});
child.on('error', (err: unknown) => {
Expand Down
49 changes: 49 additions & 0 deletions packages/interceptors/test/child-signals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { exitCodeForChild } from '@memnox/core';
import { describe, expect, it } from 'vitest';

import { relayEndingSignals } from '../src/child-signals';

function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

describe('relayEndingSignals', () => {
it('stops the real command when its wrapper is sent SIGTERM, and reports 143', async () => {
const wrapper = new EventEmitter();
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
const detach = relayEndingSignals(child, wrapper);
const ended = new Promise<number>((resolve) => {
child.on('exit', (code, signal) => {
detach();
resolve(exitCodeForChild(code, signal));
});
});

wrapper.emit('SIGTERM');

await expect(ended).resolves.toBe(143);
expect(child.pid === undefined ? false : alive(child.pid)).toBe(false);
expect(wrapper.listenerCount('SIGTERM')).toBe(0);
expect(wrapper.listenerCount('SIGHUP')).toBe(0);
});

it('relays SIGHUP as well, which a closed terminal sends', async () => {
const wrapper = new EventEmitter();
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
relayEndingSignals(child, wrapper);
const ended = new Promise<NodeJS.Signals | null>((resolve) => {
child.on('exit', (_code, signal) => resolve(signal));
});

wrapper.emit('SIGHUP');

await expect(ended).resolves.toBe('SIGHUP');
});
});
Loading