aboutsummaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
Diffstat (limited to 'api')
-rw-r--r--api/chat/lib.php25
-rw-r--r--api/chat/poll.php13
2 files changed, 29 insertions, 9 deletions
diff --git a/api/chat/lib.php b/api/chat/lib.php
index 6a0f923..94afe94 100644
--- a/api/chat/lib.php
+++ b/api/chat/lib.php
@@ -51,8 +51,15 @@ function chat_path(string $thread): string {
}
function chat_read(string $thread): array {
- $all = load_json(chat_path($thread));
- return is_array($all) ? $all : [];
+ $path = chat_path($thread);
+ if (!is_file($path)) return [];
+ $raw = @file_get_contents($path);
+ if ($raw === false) error_response('thread is unreadable', 500);
+ $all = json_decode($raw, true);
+ // A corrupt file must NOT read as "empty": the next append would then rewrite the
+ // thread with a single message and the history is gone for good.
+ if (!is_array($all)) error_response('thread file is corrupt: ' . basename($path), 500);
+ return $all;
}
// Atomic replace: a reader sees the old file or the new one, never a truncated one.
@@ -83,12 +90,17 @@ function chat_append(string $thread, string $author, string $text): array {
error_response('failed to lock thread', 500);
}
$msg = [];
+ $persisted = false;
try {
$messages = chat_read($thread);
+ $lastEpoch = $messages ? (int)($messages[count($messages) - 1]['epoch'] ?? 0) : 0;
+ $now = (int)round(microtime(true) * 1000);
$msg = [
'id' => 'MSG-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(3)),
'ts' => gmdate('c'), // UTC, always — never a local offset
- 'epoch' => (int)round(microtime(true) * 1000), // ms — second precision drops same-second messages
+ // strictly increasing: two posts in the same millisecond would otherwise share a
+ // cursor value and the second would never be delivered to a `since` reader
+ 'epoch' => max($now, $lastEpoch + 1),
'author' => $author,
'text' => $text,
];
@@ -101,13 +113,14 @@ function chat_append(string $thread, string $author, string $text): array {
if (count($messages) > CHAT_MAX_MESSAGES) {
$messages = array_slice($messages, -CHAT_MAX_MESSAGES);
}
- if (!chat_write($thread, $messages)) {
- error_response('failed to persist message', 500);
- }
+ $persisted = chat_write($thread, $messages);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
+ // error_response() exits, and PHP does not run finally blocks on exit — so the
+ // failure must be reported after the lock is released, never from inside it.
+ if (!$persisted) error_response('failed to persist message', 500);
return $msg;
}
diff --git a/api/chat/poll.php b/api/chat/poll.php
index 601beca..b4aa970 100644
--- a/api/chat/poll.php
+++ b/api/chat/poll.php
@@ -14,14 +14,21 @@ $all = chat_read($thread);
$messages = $since > 0
? array_values(array_filter($all, fn($m) => (int)($m['epoch'] ?? 0) > $since))
: $all;
-$lastAll = $all ? end($all) : null;
+
+$window = array_slice($messages, -50);
+$truncated = count($messages) > count($window);
+// The cursor is the last message the client is actually being handed. Returning the
+// thread's real tail after a truncation would skip everything in between for good.
+$lastSent = $window ? $window[count($window) - 1] : null;
+$lastAll = $all ? $all[count($all) - 1] : null;
json_response([
'success' => true,
'thread' => $thread,
- 'messages' => array_slice($messages, -50),
+ 'messages' => $window,
+ 'truncated' => $truncated,
// `awaiting` replaces `unread`: the server keeps no read state, so an unread
// counter could never clear. Who spoke last is knowable.
'awaiting' => (bool)($lastAll && ($lastAll['author'] ?? '') !== 'hermes'),
- 'cursor' => $lastAll ? (int)($lastAll['epoch'] ?? 0) : 0,
+ 'cursor' => $lastSent ? (int)($lastSent['epoch'] ?? 0) : ($lastAll ? (int)($lastAll['epoch'] ?? 0) : 0),
]);