Skip to content
Merged
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/daemon-client-close.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
---

An intercepted command now keeps its ledger row and exits with its real status when the daemon closes the connection without replying. A final reply the daemon sends without a trailing newline is read rather than dropped.
11 changes: 8 additions & 3 deletions packages/interceptors/src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ function speak(
message: DaemonRequest,
): Promise<DaemonResponse | null> {
const path = socketPathFor(home);
const timeoutMs = timeout ?? DAEMON_TIMEOUT_MS;

return new Promise((resolve) => {
let settled = false;
Expand All @@ -98,7 +97,7 @@ function speak(
resolve(value);
};

const timer = setTimeout(() => finish(null), timeoutMs);
const timer = setTimeout(() => finish(null), timeout ?? DAEMON_TIMEOUT_MS);
timer.unref?.();

const socket = connect(path, () => {
Expand All @@ -110,7 +109,13 @@ function speak(
socket.on('data', (chunk: string) => {
for (const line of reader.push(chunk)) finish(decodeResponse(line));
});
// Not running is the ordinary case, not an error worth printing.
// A reply the daemon closed on without a newline is still the reply.
socket.on('end', () => {
for (const line of reader.push('\n')) finish(decodeResponse(line));
finish(null);
});
// Not running is the ordinary case, and an unanswered close would drain the loop with this pending.
socket.on('close', () => finish(null));
socket.on('error', () => finish(null));
});
}
74 changes: 74 additions & 0 deletions packages/interceptors/test/daemon-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { createServer, type Server, type Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { socketPathFor } from '@memnox/core';
import { askDaemon, reportToDaemon } from '../src/daemon-client';

// Long enough that a hang is caught by the assertion rather than passing by timing out to null.
const PATIENT_MS = 10_000;

const homes: string[] = [];
const servers: Server[] = [];

afterEach(async () => {
await Promise.all(
servers
.splice(0)
.map((server) => new Promise<void>((done) => server.close(() => done()))),
);
await Promise.all(
homes.splice(0).map((home) => rm(home, { recursive: true, force: true })),
);
});

async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mx-dc-'));
homes.push(dir);
return dir;
}

async function daemon(dir: string, onRequest: (socket: Socket) => void): Promise<void> {
const path = socketPathFor(dir);
await mkdir(dirname(path), { recursive: true });
const server = createServer((socket) => {
socket.once('data', () => onRequest(socket));
});
servers.push(server);
await new Promise<void>((done) => server.listen(path, done));
}

describe('the daemon client', () => {
it('resolves to null when the daemon ends the connection without replying', async () => {
const dir = await home();
await daemon(dir, (socket) => socket.end());

const started = Date.now();
const answer = await reportToDaemon(dir, {
action: 'shell.run',
exitCode: 3,
timeoutMs: PATIENT_MS,
});

expect(answer).toBeNull();
expect(Date.now() - started).toBeLessThan(PATIENT_MS);
});

it('reads a reply that closes without a trailing newline', async () => {
const dir = await home();
await daemon(dir, (socket) => socket.end(JSON.stringify({ id: 1, ok: true })));

const answer = await askDaemon(dir, { action: 'git.push', timeoutMs: PATIENT_MS });

expect(answer).toEqual({ id: 1, ok: true });
});

it('resolves to null when there is no socket file', async () => {
const dir = await home();

const answer = await askDaemon(dir, { action: 'git.push', timeoutMs: PATIENT_MS });

expect(answer).toBeNull();
});
});
13 changes: 8 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,15 @@ packages:
allowBuilds:
better-sqlite3: true
esbuild: true

# source-map-js is a transitive dev dependency of vitest and tsup pulled below
# GHSA-68fv-2mgg-jv7q; the audit fails the build at high, so hold it at the patched line.
overrides:
source-map-js: '>=1.2.2'

# braces is a transitive dev dependency of changesets, knip and vitest with no patched
# release yet (GHSA-vfj7-8cjw-p6xm lists none and 3.0.3 is still the latest), so the only
# way past the gate is to ignore it. Drop this line the day a fixed braces ships.
auditConfig:
ignoreGhsas:
- GHSA-vfj7-8cjw-p6xm
Loading