1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
#![cfg(all(unix, feature = "slow-tests"))]
//! Suite 6 — signal handling tests (Unix only).
//!
//! Each test spawns the binary as a real subprocess, sends a signal via
//! `libc::kill`, waits with `.wait()` and checks the exit status and the
//! database integrity.
//!
//! This suite is compiled and executed ONLY on Unix systems. On Windows it is
//! silently omitted by the `#![cfg(unix)]` directive.
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tempfile::TempDir;
#[path = "common/mod.rs"]
mod common;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn bin_path() -> PathBuf {
assert_cmd::cargo::cargo_bin("sqlite-graphrag")
}
/// Creates an isolated TempDir and initializes the database before returning.
fn setup_db() -> TempDir {
let tmp = TempDir::new().expect("TempDir failed");
let mock_dir = common::mock_llm_path();
let db = tmp.path().join("test.sqlite");
let mut c = Command::new(bin_path());
c.env("PATH", common::prepend_path(&mock_dir));
// GAP-SG-101: product env is not read (G-T-XDG-04).
common::wire_std_cmd(tmp.path(), &mut c, &db);
let status = c
.args(["init", "--db"])
.arg(&db)
.status()
.expect("init failed");
assert!(status.success(), "init deve ter sucesso: {status:?}");
tmp
}
/// Builds a Command for the binary with full isolation.
fn sqlite_graphrag_cmd(tmp: &TempDir) -> Command {
let mock_dir = common::mock_llm_path();
let mut cmd = Command::new(bin_path());
let db = tmp.path().join("test.sqlite");
cmd.env("PATH", common::prepend_path(&mock_dir));
common::wire_std_cmd(tmp.path(), &mut cmd, &db);
cmd
}
/// Sends `signal` to the `child` process using `libc::kill`.
/// Returns `Ok(())` if the syscall returned 0, `Err(errno)` otherwise.
fn send_signal(child: &Child, signal: libc::c_int) -> Result<(), i32> {
let pid = child.id() as libc::pid_t;
let ret = unsafe { libc::kill(pid, signal) };
if ret == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(-1))
}
}
/// Checks the SQLite database integrity using `PRAGMA integrity_check`.
/// Returns `true` when the result is "ok".
fn db_integro(tmp: &TempDir) -> bool {
let db_path = tmp.path().join("test.sqlite");
if !db_path.exists() {
return false;
}
let conn = rusqlite::Connection::open(&db_path);
match conn {
Err(_) => false,
Ok(c) => {
let resultado: String = c
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.unwrap_or_else(|_| "failed".to_string());
resultado.trim() == "ok"
}
}
}
// ---------------------------------------------------------------------------
// Suite 6 — Testes de signal handling
// ---------------------------------------------------------------------------
/// SIGINT during `health` must terminate the process and DB stays intact.
///
/// `health` is a lightweight command that returns quickly, but we validate that
/// after SIGINT the process exits with signal (exit status shows signal=2)
/// and the database remains valid.
#[test]
fn sigint_during_health_exits_with_db_integrity() {
let tmp = setup_db();
let mut child: Child = sqlite_graphrag_cmd(&tmp)
.arg("health")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn de health failed");
// Minimum delay to ensure process has started
std::thread::sleep(Duration::from_millis(50));
// Send SIGINT; ignore ESRCH (errno 3) if process already exited
match send_signal(&child, libc::SIGINT) {
Ok(()) => {}
Err(3) => {} // ESRCH: processo já encerrou — tudo bem
Err(e) => panic!("kill(SIGINT) failed com errno={e}"),
}
let status = child.wait().expect("wait failed");
// Process exited normally (exit 0) or by signal — both acceptable
// What matters is that no panic occurred and the DB is intact
let _ = status; // exit code depende de timing — não assertamos valor fixo
assert!(
db_integro(&tmp),
"DB deve estar íntegro após SIGINT em health"
);
}
/// SIGTERM during `init` on an already-initialized database must shut down gracefully.
///
/// Tests that the binary handles SIGTERM without database corruption.
/// The process may finish with exit 0 (completed before the signal) or
/// with signal code — both are valid, but DB must be intact.
#[test]
fn sigterm_during_init_graceful_exit_db_integrity() {
let tmp = TempDir::new().expect("TempDir failed");
let mut child: Child = sqlite_graphrag_cmd(&tmp)
.arg("init")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn de init failed");
// Wait briefly for the process to start running
std::thread::sleep(Duration::from_millis(100));
match send_signal(&child, libc::SIGTERM) {
Ok(()) => {}
Err(3) => {} // ESRCH: processo já encerrou
Err(e) => panic!("kill(SIGTERM) failed com errno={e}"),
}
let status = child.wait().expect("wait failed");
// Accept both exit 0 (completed before signal) and signal termination
let encerrou_ok =
status.success() || status.signal().is_some() || status.code().is_some_and(|c| c != 0);
assert!(
encerrou_ok,
"Processo deveria ter encerrado mas wait retornou status indefinido"
);
// If the DB was created, it must be intact
let db_path = tmp.path().join("test.sqlite");
if db_path.exists() {
assert!(
db_integro(&tmp),
"DB criado deve estar íntegro após SIGTERM"
);
}
}
/// A process receiving SIGTERM after `remember` with a populated database does not corrupt the DB.
#[test]
fn sigterm_after_remember_does_not_corrupt_db() {
let tmp = setup_db();
// Primeiro remember sem sinal — deve completar normalmente
let status = sqlite_graphrag_cmd(&tmp)
.args([
"remember",
"--name",
"memoria-signal-test",
"--type",
"project",
"--description",
"Teste de signal handling",
"--body",
"Conteudo para testar integridade apos sinal",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("remember failed");
assert!(
status.success(),
"remember deve ter sucesso antes do teste de sinal"
);
// Second remember with SIGTERM during execution
let mut child: Child = sqlite_graphrag_cmd(&tmp)
.args([
"remember",
"--name",
"memoria-signal-test-2",
"--type",
"project",
"--description",
"Segundo remember durante sinal",
"--body",
"Conteudo do segundo remember",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn de segundo remember failed");
std::thread::sleep(Duration::from_millis(50));
match send_signal(&child, libc::SIGTERM) {
Ok(()) => {}
Err(3) => {}
Err(e) => panic!("kill(SIGTERM) failed com errno={e}"),
}
let _ = child.wait().expect("wait failed");
// DB must be intact after signal — critical invariant
assert!(
db_integro(&tmp),
"DB deve estar íntegro após SIGTERM durante remember"
);
}
/// Verifies that the process does not enter an infinite loop or zombie state after SIGKILL.
///
/// SIGKILL cannot be intercepted — the kernel terminates the process
/// immediately. The database may be in a partial state, but `.wait()` must
/// return without blocking.
#[test]
fn sigkill_process_does_not_become_zombie() {
let tmp = setup_db();
let mut child: Child = sqlite_graphrag_cmd(&tmp)
.arg("health")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn de health failed");
std::thread::sleep(Duration::from_millis(30));
match send_signal(&child, libc::SIGKILL) {
Ok(()) => {}
Err(3) => {}
Err(e) => panic!("kill(SIGKILL) failed com errno={e}"),
}
// `.wait()` must return without blocking — process must not become zombie
let status = child.wait().expect("wait deve retornar apos SIGKILL");
// Critical invariant: `.wait()` returned without blocking (not a zombie).
// The process may have exited before SIGKILL (exit 0) or by SIGKILL (signal 9).
// Both cases are valid — only a deadlock in `.wait()` would be a real failure.
let wait_retornou =
status.success() || status.signal().is_some_and(|s| s == 9) || !status.success();
assert!(
wait_retornou,
"Processo deveria ter encerrado mas wait bloqueou ou retornou estado indefinido: {status:?}"
);
}